diff options
Diffstat (limited to 'apps')
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'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're sorry, but an unexpected error occurred. Please try again - or contact support if the issue persists. - </p> - </div> - - {/* Action Buttons */} - <div className="space-y-3"> - <Button className="w-full" onClick={() => window.location.reload()}> - <RefreshCw className="mr-2 h-4 w-4" /> - Try Again - </Button> - - <Link href="/" className="block"> - <Button variant="outline" className="w-full"> - <Home className="mr-2 h-4 w-4" /> - Go Home - </Button> - </Link> - </div> - </div> - </div> - ); + return <ErrorFallback />; } diff --git a/apps/web/app/dashboard/highlights/page.tsx b/apps/web/app/dashboard/highlights/page.tsx index 5945de00..ed0b16c0 100644 --- a/apps/web/app/dashboard/highlights/page.tsx +++ b/apps/web/app/dashboard/highlights/page.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next"; import AllHighlights from "@/components/dashboard/highlights/AllHighlights"; -import { Separator } from "@/components/ui/separator"; import { useTranslation } from "@/lib/i18n/server"; import { api } from "@/server/api/client"; import { Highlighter } from "lucide-react"; @@ -18,13 +17,14 @@ export default async function HighlightsPage() { const { t } = await useTranslation(); const highlights = await api.highlights.getAll({}); return ( - <div className="flex flex-col gap-8 rounded-md border bg-background p-4"> - <span className="flex items-center gap-1 text-2xl"> - <Highlighter className="size-6" /> - {t("common.highlights")} - </span> - <Separator /> - <AllHighlights highlights={highlights} /> + <div className="flex flex-col gap-4"> + <div className="flex items-center"> + <Highlighter className="mr-2" /> + <p className="text-2xl">{t("common.highlights")}</p> + </div> + <div className="flex flex-col gap-8 rounded-md border bg-background p-4"> + <AllHighlights highlights={highlights} /> + </div> </div> ); } diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx index 911d542c..be65e66a 100644 --- a/apps/web/app/dashboard/layout.tsx +++ b/apps/web/app/dashboard/layout.tsx @@ -4,6 +4,7 @@ import MobileSidebar from "@/components/shared/sidebar/MobileSidebar"; import Sidebar from "@/components/shared/sidebar/Sidebar"; import SidebarLayout from "@/components/shared/sidebar/SidebarLayout"; import { Separator } from "@/components/ui/separator"; +import { ReaderSettingsProvider } from "@/lib/readerSettings"; import { UserSettingsContextProvider } from "@/lib/userSettings"; import { api } from "@/server/api/client"; import { getServerAuthSession } from "@/server/auth"; @@ -98,23 +99,25 @@ export default async function Dashboard({ return ( <UserSettingsContextProvider userSettings={userSettings.data}> - <SidebarLayout - sidebar={ - <Sidebar - items={items} - extraSections={ - <> - <Separator /> - <AllLists initialData={lists.data} /> - </> - } - /> - } - mobileSidebar={<MobileSidebar items={mobileSidebar} />} - modal={modal} - > - {children} - </SidebarLayout> + <ReaderSettingsProvider> + <SidebarLayout + sidebar={ + <Sidebar + items={items} + extraSections={ + <> + <Separator /> + <AllLists initialData={lists.data} /> + </> + } + /> + } + mobileSidebar={<MobileSidebar items={mobileSidebar} />} + modal={modal} + > + {children} + </SidebarLayout> + </ReaderSettingsProvider> </UserSettingsContextProvider> ); } diff --git a/apps/web/app/dashboard/lists/page.tsx b/apps/web/app/dashboard/lists/page.tsx index 7950cd76..2f9e54c6 100644 --- a/apps/web/app/dashboard/lists/page.tsx +++ b/apps/web/app/dashboard/lists/page.tsx @@ -1,8 +1,10 @@ import AllListsView from "@/components/dashboard/lists/AllListsView"; +import { EditListModal } from "@/components/dashboard/lists/EditListModal"; import { PendingInvitationsCard } from "@/components/dashboard/lists/PendingInvitationsCard"; -import { Separator } from "@/components/ui/separator"; +import { Button } from "@/components/ui/button"; import { useTranslation } from "@/lib/i18n/server"; import { api } from "@/server/api/client"; +import { Plus } from "lucide-react"; export default async function ListsPage() { // oxlint-disable-next-line rules-of-hooks @@ -11,10 +13,17 @@ export default async function ListsPage() { return ( <div className="flex flex-col gap-4"> + <div className="flex items-center justify-between"> + <p className="text-2xl">📋 {t("lists.all_lists")}</p> + <EditListModal> + <Button className="flex items-center"> + <Plus className="mr-2 size-4" /> + <span>{t("lists.new_list")}</span> + </Button> + </EditListModal> + </div> <PendingInvitationsCard /> <div className="flex flex-col gap-3 rounded-md border bg-background p-4"> - <p className="text-2xl">📋 {t("lists.all_lists")}</p> - <Separator /> <AllListsView initialData={lists.lists} /> </div> </div> diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 8514b8ad..ba09a973 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -8,11 +8,11 @@ import "@karakeep/tailwind-config/globals.css"; import type { Viewport } from "next"; import React from "react"; -import { Toaster } from "@/components/ui/toaster"; import Providers from "@/lib/providers"; import { getUserLocalSettings } from "@/lib/userLocalSettings/userLocalSettings"; import { getServerAuthSession } from "@/server/auth"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { Toaster } from "sonner"; import { clientConfig } from "@karakeep/shared/config"; diff --git a/apps/web/app/logout/page.tsx b/apps/web/app/logout/page.tsx index 91ad684d..1e43622e 100644 --- a/apps/web/app/logout/page.tsx +++ b/apps/web/app/logout/page.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { signOut } from "next-auth/react"; +import { signOut } from "@/lib/auth/client"; import { useSearchHistory } from "@karakeep/shared-react/hooks/search-history"; diff --git a/apps/web/app/reader/[bookmarkId]/page.tsx b/apps/web/app/reader/[bookmarkId]/page.tsx index e32811a9..0ba72016 100644 --- a/apps/web/app/reader/[bookmarkId]/page.tsx +++ b/apps/web/app/reader/[bookmarkId]/page.tsx @@ -3,63 +3,42 @@ import { Suspense, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import HighlightCard from "@/components/dashboard/highlights/HighlightCard"; +import ReaderSettingsPopover from "@/components/dashboard/preview/ReaderSettingsPopover"; import ReaderView from "@/components/dashboard/preview/ReaderView"; import { Button } from "@/components/ui/button"; import { FullPageSpinner } from "@/components/ui/full-page-spinner"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; -import { Slider } from "@/components/ui/slider"; -import { - HighlighterIcon as Highlight, - Minus, - Plus, - Printer, - Settings, - Type, - X, -} from "lucide-react"; -import { useSession } from "next-auth/react"; +import { useSession } from "@/lib/auth/client"; +import { useReaderSettings } from "@/lib/readerSettings"; +import { useQuery } from "@tanstack/react-query"; +import { HighlighterIcon as Highlight, Printer, X } from "lucide-react"; -import { api } from "@karakeep/shared-react/trpc"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; +import { READER_FONT_FAMILIES } from "@karakeep/shared/types/readers"; import { getBookmarkTitle } from "@karakeep/shared/utils/bookmarkUtils"; export default function ReaderViewPage() { + const api = useTRPC(); const params = useParams<{ bookmarkId: string }>(); const bookmarkId = params.bookmarkId; - const { data: highlights } = api.highlights.getForBookmark.useQuery({ - bookmarkId, - }); - const { data: bookmark } = api.bookmarks.getBookmark.useQuery({ - bookmarkId, - }); + const { data: highlights } = useQuery( + api.highlights.getForBookmark.queryOptions({ + bookmarkId, + }), + ); + const { data: bookmark } = useQuery( + api.bookmarks.getBookmark.queryOptions({ + bookmarkId, + }), + ); const { data: session } = useSession(); const router = useRouter(); - const [fontSize, setFontSize] = useState([18]); - const [lineHeight, setLineHeight] = useState([1.6]); - const [fontFamily, setFontFamily] = useState("serif"); + const { settings } = useReaderSettings(); const [showHighlights, setShowHighlights] = useState(false); - const [showSettings, setShowSettings] = useState(false); const isOwner = session?.user?.id === bookmark?.userId; - const fontFamilies = { - serif: "ui-serif, Georgia, Cambria, serif", - sans: "ui-sans-serif, system-ui, sans-serif", - mono: "ui-monospace, Menlo, Monaco, monospace", - }; - const onClose = () => { if (window.history.length > 1) { router.back(); @@ -89,94 +68,7 @@ export default function ReaderViewPage() { <Printer className="h-4 w-4" /> </Button> - <Popover open={showSettings} onOpenChange={setShowSettings}> - <PopoverTrigger asChild> - <Button variant="ghost" size="icon"> - <Settings className="h-4 w-4" /> - </Button> - </PopoverTrigger> - <PopoverContent side="bottom" align="end" className="w-80"> - <div className="space-y-4"> - <div className="flex items-center gap-2 pb-2"> - <Type className="h-4 w-4" /> - <h3 className="font-semibold">Reading Settings</h3> - </div> - - <div className="space-y-4"> - <div className="space-y-2"> - <label className="text-sm font-medium">Font Family</label> - <Select value={fontFamily} onValueChange={setFontFamily}> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="serif">Serif</SelectItem> - <SelectItem value="sans">Sans Serif</SelectItem> - <SelectItem value="mono">Monospace</SelectItem> - </SelectContent> - </Select> - </div> - - <div className="space-y-2"> - <div className="flex items-center justify-between"> - <label className="text-sm font-medium">Font Size</label> - <span className="text-sm text-muted-foreground"> - {fontSize[0]}px - </span> - </div> - <div className="flex items-center gap-2"> - <Button - variant="outline" - size="icon" - className="h-7 w-7 bg-transparent" - onClick={() => - setFontSize([Math.max(12, fontSize[0] - 1)]) - } - > - <Minus className="h-3 w-3" /> - </Button> - <Slider - value={fontSize} - onValueChange={setFontSize} - max={24} - min={12} - step={1} - className="flex-1" - /> - <Button - variant="outline" - size="icon" - className="h-7 w-7 bg-transparent" - onClick={() => - setFontSize([Math.min(24, fontSize[0] + 1)]) - } - > - <Plus className="h-3 w-3" /> - </Button> - </div> - </div> - - <div className="space-y-2"> - <div className="flex items-center justify-between"> - <label className="text-sm font-medium"> - Line Height - </label> - <span className="text-sm text-muted-foreground"> - {lineHeight[0]} - </span> - </div> - <Slider - value={lineHeight} - onValueChange={setLineHeight} - max={2.5} - min={1.2} - step={0.1} - /> - </div> - </div> - </div> - </PopoverContent> - </Popover> + <ReaderSettingsPopover variant="ghost" /> <Button variant={showHighlights ? "default" : "ghost"} @@ -216,10 +108,9 @@ export default function ReaderViewPage() { <h1 className="font-bold leading-tight" style={{ - fontFamily: - fontFamilies[fontFamily as keyof typeof fontFamilies], - fontSize: `${fontSize[0] * 1.8}px`, - lineHeight: lineHeight[0] * 0.9, + fontFamily: READER_FONT_FAMILIES[settings.fontFamily], + fontSize: `${settings.fontSize * 1.8}px`, + lineHeight: settings.lineHeight * 0.9, }} > {getBookmarkTitle(bookmark)} @@ -239,10 +130,9 @@ export default function ReaderViewPage() { <ReaderView className="prose prose-neutral max-w-none break-words dark:prose-invert [&_code]:break-all [&_img]:h-auto [&_img]:max-w-full [&_pre]:overflow-x-auto [&_table]:block [&_table]:overflow-x-auto" style={{ - fontFamily: - fontFamilies[fontFamily as keyof typeof fontFamilies], - fontSize: `${fontSize[0]}px`, - lineHeight: lineHeight[0], + fontFamily: READER_FONT_FAMILIES[settings.fontFamily], + fontSize: `${settings.fontSize}px`, + lineHeight: settings.lineHeight, }} bookmarkId={bookmarkId} readOnly={!isOwner} @@ -256,20 +146,6 @@ export default function ReaderViewPage() { </article> </main> - {/* Mobile backdrop */} - {showHighlights && ( - <button - className="fixed inset-0 top-14 z-40 bg-black/50 lg:hidden" - onClick={() => setShowHighlights(false)} - onKeyDown={(e) => { - if (e.key === "Escape") { - setShowHighlights(false); - } - }} - aria-label="Close highlights sidebar" - /> - )} - {/* Highlights Sidebar */} {showHighlights && highlights && ( <aside className="fixed right-0 top-14 z-50 h-[calc(100vh-3.5rem)] w-full border-l bg-background sm:w-80 lg:z-auto lg:bg-background/95 lg:backdrop-blur lg:supports-[backdrop-filter]:bg-background/60 print:hidden"> diff --git a/apps/web/app/reader/layout.tsx b/apps/web/app/reader/layout.tsx new file mode 100644 index 00000000..b0c27c84 --- /dev/null +++ b/apps/web/app/reader/layout.tsx @@ -0,0 +1,39 @@ +import { redirect } from "next/navigation"; +import { ReaderSettingsProvider } from "@/lib/readerSettings"; +import { UserSettingsContextProvider } from "@/lib/userSettings"; +import { api } from "@/server/api/client"; +import { getServerAuthSession } from "@/server/auth"; +import { TRPCError } from "@trpc/server"; + +import { tryCatch } from "@karakeep/shared/tryCatch"; + +export default async function ReaderLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const session = await getServerAuthSession(); + if (!session) { + redirect("/"); + } + + const userSettings = await tryCatch(api.users.settings()); + + if (userSettings.error) { + if (userSettings.error instanceof TRPCError) { + if ( + userSettings.error.code === "NOT_FOUND" || + userSettings.error.code === "UNAUTHORIZED" + ) { + redirect("/logout"); + } + } + throw userSettings.error; + } + + return ( + <UserSettingsContextProvider userSettings={userSettings.data}> + <ReaderSettingsProvider>{children}</ReaderSettingsProvider> + </UserSettingsContextProvider> + ); +} diff --git a/apps/web/app/settings/assets/page.tsx b/apps/web/app/settings/assets/page.tsx index 14144455..77b3d159 100644 --- a/apps/web/app/settings/assets/page.tsx +++ b/apps/web/app/settings/assets/page.tsx @@ -5,6 +5,7 @@ import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; import { Button } from "@/components/ui/button"; import { FullPageSpinner } from "@/components/ui/full-page-spinner"; +import { toast } from "@/components/ui/sonner"; import { Table, TableBody, @@ -13,14 +14,14 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { toast } from "@/components/ui/use-toast"; import { ASSET_TYPE_TO_ICON } from "@/lib/attachments"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { formatBytes } from "@/lib/utils"; +import { useInfiniteQuery } from "@tanstack/react-query"; import { ExternalLink, Trash2 } from "lucide-react"; import { useDetachBookmarkAsset } from "@karakeep/shared-react/hooks/assets"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { getAssetUrl } from "@karakeep/shared/utils/assetUtils"; import { humanFriendlyNameForAssertType, @@ -28,6 +29,7 @@ import { } from "@karakeep/trpc/lib/attachments"; export default function AssetsSettingsPage() { + const api = useTRPC(); const { t } = useTranslation(); const { mutate: detachAsset, isPending: isDetaching } = useDetachBookmarkAsset({ @@ -49,13 +51,15 @@ export default function AssetsSettingsPage() { fetchNextPage, hasNextPage, isFetchingNextPage, - } = api.assets.list.useInfiniteQuery( - { - limit: 20, - }, - { - getNextPageParam: (lastPage) => lastPage.nextCursor, - }, + } = useInfiniteQuery( + api.assets.list.infiniteQueryOptions( + { + limit: 20, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ), ); const assets = data?.pages.flatMap((page) => page.assets) ?? []; diff --git a/apps/web/app/settings/broken-links/page.tsx b/apps/web/app/settings/broken-links/page.tsx index e2b42d07..4197d62e 100644 --- a/apps/web/app/settings/broken-links/page.tsx +++ b/apps/web/app/settings/broken-links/page.tsx @@ -2,6 +2,7 @@ import { ActionButton } from "@/components/ui/action-button"; import { FullPageSpinner } from "@/components/ui/full-page-spinner"; +import { toast } from "@/components/ui/sonner"; import { Table, TableBody, @@ -10,7 +11,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { toast } from "@/components/ui/use-toast"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { RefreshCw, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -18,20 +19,23 @@ import { useDeleteBookmark, useRecrawlBookmark, } from "@karakeep/shared-react/hooks/bookmarks"; -import { api } from "@karakeep/shared-react/trpc"; +import { useTRPC } from "@karakeep/shared-react/trpc"; export default function BrokenLinksPage() { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); - const { data, isPending } = api.bookmarks.getBrokenLinks.useQuery(); + const queryClient = useQueryClient(); + const { data, isPending } = useQuery( + api.bookmarks.getBrokenLinks.queryOptions(), + ); const { mutate: deleteBookmark, isPending: isDeleting } = useDeleteBookmark({ onSuccess: () => { toast({ description: t("toasts.bookmarks.deleted"), }); - apiUtils.bookmarks.getBrokenLinks.invalidate(); + queryClient.invalidateQueries(api.bookmarks.getBrokenLinks.pathFilter()); }, onError: () => { toast({ @@ -47,7 +51,9 @@ export default function BrokenLinksPage() { toast({ description: t("toasts.bookmarks.refetch"), }); - apiUtils.bookmarks.getBrokenLinks.invalidate(); + queryClient.invalidateQueries( + api.bookmarks.getBrokenLinks.pathFilter(), + ); }, onError: () => { toast({ diff --git a/apps/web/app/settings/import/[sessionId]/page.tsx b/apps/web/app/settings/import/[sessionId]/page.tsx new file mode 100644 index 00000000..968de13a --- /dev/null +++ b/apps/web/app/settings/import/[sessionId]/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next"; +import ImportSessionDetail from "@/components/settings/ImportSessionDetail"; +import { useTranslation } from "@/lib/i18n/server"; + +export async function generateMetadata(): Promise<Metadata> { + // oxlint-disable-next-line rules-of-hooks + const { t } = await useTranslation(); + return { + title: `${t("settings.import_sessions.detail.page_title")} | Karakeep`, + }; +} + +export default async function ImportSessionDetailPage({ + params, +}: { + params: Promise<{ sessionId: string }>; +}) { + const { sessionId } = await params; + return <ImportSessionDetail sessionId={sessionId} />; +} diff --git a/apps/web/app/settings/info/page.tsx b/apps/web/app/settings/info/page.tsx index 1807b538..da9b2e51 100644 --- a/apps/web/app/settings/info/page.tsx +++ b/apps/web/app/settings/info/page.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next"; import { ChangePassword } from "@/components/settings/ChangePassword"; import { DeleteAccount } from "@/components/settings/DeleteAccount"; +import ReaderSettings from "@/components/settings/ReaderSettings"; +import UserAvatar from "@/components/settings/UserAvatar"; import UserDetails from "@/components/settings/UserDetails"; import UserOptions from "@/components/settings/UserOptions"; import { useTranslation } from "@/lib/i18n/server"; @@ -16,9 +18,11 @@ export async function generateMetadata(): Promise<Metadata> { export default async function InfoPage() { return ( <div className="flex flex-col gap-4"> + <UserAvatar /> <UserDetails /> <ChangePassword /> <UserOptions /> + <ReaderSettings /> <DeleteAccount /> </div> ); diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx index 1c7d25ac..8d211e53 100644 --- a/apps/web/app/settings/layout.tsx +++ b/apps/web/app/settings/layout.tsx @@ -1,8 +1,12 @@ +import { redirect } from "next/navigation"; import MobileSidebar from "@/components/shared/sidebar/MobileSidebar"; import Sidebar from "@/components/shared/sidebar/Sidebar"; import SidebarLayout from "@/components/shared/sidebar/SidebarLayout"; +import { ReaderSettingsProvider } from "@/lib/readerSettings"; import { UserSettingsContextProvider } from "@/lib/userSettings"; import { api } from "@/server/api/client"; +import { getServerAuthSession } from "@/server/auth"; +import { TRPCError } from "@trpc/server"; import { TFunction } from "i18next"; import { ArrowLeft, @@ -21,6 +25,7 @@ import { } from "lucide-react"; import serverConfig from "@karakeep/shared/config"; +import { tryCatch } from "@karakeep/shared/tryCatch"; const settingsSidebarItems = ( t: TFunction, @@ -111,15 +116,35 @@ export default async function SettingsLayout({ }: Readonly<{ children: React.ReactNode; }>) { - const userSettings = await api.users.settings(); + const session = await getServerAuthSession(); + if (!session) { + redirect("/"); + } + + const userSettings = await tryCatch(api.users.settings()); + + if (userSettings.error) { + if (userSettings.error instanceof TRPCError) { + if ( + userSettings.error.code === "NOT_FOUND" || + userSettings.error.code === "UNAUTHORIZED" + ) { + redirect("/logout"); + } + } + throw userSettings.error; + } + return ( - <UserSettingsContextProvider userSettings={userSettings}> - <SidebarLayout - sidebar={<Sidebar items={settingsSidebarItems} />} - mobileSidebar={<MobileSidebar items={settingsSidebarItems} />} - > - {children} - </SidebarLayout> + <UserSettingsContextProvider userSettings={userSettings.data}> + <ReaderSettingsProvider> + <SidebarLayout + sidebar={<Sidebar items={settingsSidebarItems} />} + mobileSidebar={<MobileSidebar items={settingsSidebarItems} />} + > + {children} + </SidebarLayout> + </ReaderSettingsProvider> </UserSettingsContextProvider> ); } diff --git a/apps/web/app/settings/rules/page.tsx b/apps/web/app/settings/rules/page.tsx index 98a30bcc..2e739343 100644 --- a/apps/web/app/settings/rules/page.tsx +++ b/apps/web/app/settings/rules/page.tsx @@ -6,22 +6,25 @@ import RuleList from "@/components/dashboard/rules/RuleEngineRuleList"; import { Button } from "@/components/ui/button"; import { FullPageSpinner } from "@/components/ui/full-page-spinner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; -import { Tooltip, TooltipContent, TooltipTrigger } from "components/ui/tooltip"; -import { FlaskConical, PlusCircle } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { PlusCircle } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { RuleEngineRule } from "@karakeep/shared/types/rules"; export default function RulesSettingsPage() { + const api = useTRPC(); const { t } = useTranslation(); const [editingRule, setEditingRule] = useState< (Omit<RuleEngineRule, "id"> & { id: string | null }) | null >(null); - const { data: rules, isLoading } = api.rules.list.useQuery(undefined, { - refetchOnWindowFocus: true, - refetchOnMount: true, - }); + const { data: rules, isLoading } = useQuery( + api.rules.list.queryOptions(undefined, { + refetchOnWindowFocus: true, + refetchOnMount: true, + }), + ); const handleCreateRule = () => { const newRule = { @@ -49,14 +52,6 @@ export default function RulesSettingsPage() { <div className="flex items-center justify-between"> <span className="flex items-center gap-2 text-lg font-medium"> {t("settings.rules.rules")} - <Tooltip> - <TooltipTrigger className="text-muted-foreground"> - <FlaskConical size={15} /> - </TooltipTrigger> - <TooltipContent side="bottom"> - {t("common.experimental")} - </TooltipContent> - </Tooltip> </span> <Button onClick={handleCreateRule} variant="default"> <PlusCircle className="mr-2 h-4 w-4" /> diff --git a/apps/web/app/settings/stats/page.tsx b/apps/web/app/settings/stats/page.tsx index 944d1c59..a8896a03 100644 --- a/apps/web/app/settings/stats/page.tsx +++ b/apps/web/app/settings/stats/page.tsx @@ -6,7 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { Skeleton } from "@/components/ui/skeleton"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; import { Archive, BarChart3, @@ -32,6 +32,7 @@ import { } from "lucide-react"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { zBookmarkSourceSchema } from "@karakeep/shared/types/bookmarks"; type BookmarkSource = z.infer<typeof zBookmarkSourceSchema>; @@ -159,9 +160,10 @@ function StatCard({ } export default function StatsPage() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: stats, isLoading } = api.users.stats.useQuery(); - const { data: userSettings } = api.users.settings.useQuery(); + const { data: stats, isLoading } = useQuery(api.users.stats.queryOptions()); + const { data: userSettings } = useQuery(api.users.settings.queryOptions()); const maxHourlyActivity = useMemo(() => { if (!stats) return 0; @@ -222,20 +224,21 @@ export default function StatsPage() { return ( <div className="space-y-6"> - <div> - <h1 className="text-3xl font-bold"> - {t("settings.stats.usage_statistics")} - </h1> - <p className="text-muted-foreground"> - Insights into your bookmarking habits and collection - {userSettings?.timezone && userSettings.timezone !== "UTC" && ( - <span className="block text-sm"> - Times shown in {userSettings.timezone} timezone - </span> - )} - </p> + <div className="flex items-start justify-between"> + <div> + <h1 className="text-3xl font-bold"> + {t("settings.stats.usage_statistics")} + </h1> + <p className="text-muted-foreground"> + Insights into your bookmarking habits and collection + {userSettings?.timezone && userSettings.timezone !== "UTC" && ( + <span className="block text-sm"> + Times shown in {userSettings.timezone} timezone + </span> + )} + </p> + </div> </div> - {/* Overview Stats */} <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <StatCard @@ -287,7 +290,6 @@ export default function StatsPage() { description={t("settings.stats.overview.bookmarks_added")} /> </div> - <div className="grid gap-6 md:grid-cols-2"> {/* Bookmark Types */} <Card> @@ -530,7 +532,6 @@ export default function StatsPage() { </CardContent> </Card> </div> - {/* Activity Patterns */} <div className="grid gap-6 md:grid-cols-2"> {/* Hourly Activity */} @@ -581,7 +582,6 @@ export default function StatsPage() { </CardContent> </Card> </div> - {/* Asset Storage */} {stats.assetsByType.length > 0 && ( <Card> diff --git a/apps/web/app/signup/page.tsx b/apps/web/app/signup/page.tsx index ee77f65e..5c8b943e 100644 --- a/apps/web/app/signup/page.tsx +++ b/apps/web/app/signup/page.tsx @@ -3,10 +3,19 @@ import KarakeepLogo from "@/components/KarakeepIcon"; import SignUpForm from "@/components/signup/SignUpForm"; import { getServerAuthSession } from "@/server/auth"; -export default async function SignUpPage() { +import { validateRedirectUrl } from "@karakeep/shared/utils/redirectUrl"; + +export default async function SignUpPage({ + searchParams, +}: { + searchParams: Promise<{ redirectUrl?: string }>; +}) { const session = await getServerAuthSession(); + const { redirectUrl: rawRedirectUrl } = await searchParams; + const redirectUrl = validateRedirectUrl(rawRedirectUrl) ?? "/"; + if (session) { - redirect("/"); + redirect(redirectUrl); } return ( @@ -15,7 +24,7 @@ export default async function SignUpPage() { <div className="flex items-center justify-center"> <KarakeepLogo height={80} /> </div> - <SignUpForm /> + <SignUpForm redirectUrl={redirectUrl} /> </div> </div> ); diff --git a/apps/web/app/verify-email/page.tsx b/apps/web/app/verify-email/page.tsx index da9b8b6b..5044c63e 100644 --- a/apps/web/app/verify-email/page.tsx +++ b/apps/web/app/verify-email/page.tsx @@ -11,10 +11,17 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { api } from "@/lib/trpc"; +import { useMutation } from "@tanstack/react-query"; import { CheckCircle, Loader2, XCircle } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; +import { + isMobileAppRedirect, + validateRedirectUrl, +} from "@karakeep/shared/utils/redirectUrl"; + export default function VerifyEmailPage() { + const api = useTRPC(); const searchParams = useSearchParams(); const router = useRouter(); const [status, setStatus] = useState<"loading" | "success" | "error">( @@ -24,33 +31,51 @@ export default function VerifyEmailPage() { const token = searchParams.get("token"); const email = searchParams.get("email"); + const redirectUrl = + validateRedirectUrl(searchParams.get("redirectUrl")) ?? "/"; - const verifyEmailMutation = api.users.verifyEmail.useMutation({ - onSuccess: () => { - setStatus("success"); - setMessage( - "Your email has been successfully verified! You can now sign in.", - ); - }, - onError: (error) => { - setStatus("error"); - setMessage( - error.message || - "Failed to verify email. The link may be invalid or expired.", - ); - }, - }); + const verifyEmailMutation = useMutation( + api.users.verifyEmail.mutationOptions({ + onSuccess: () => { + setStatus("success"); + if (isMobileAppRedirect(redirectUrl)) { + setMessage( + "Your email has been successfully verified! Redirecting to the app...", + ); + // Redirect to mobile app after a brief delay + setTimeout(() => { + window.location.href = redirectUrl; + }, 1500); + } else { + setMessage( + "Your email has been successfully verified! You can now sign in.", + ); + } + }, + onError: (error) => { + setStatus("error"); + setMessage( + error.message || + "Failed to verify email. The link may be invalid or expired.", + ); + }, + }), + ); - const resendEmailMutation = api.users.resendVerificationEmail.useMutation({ - onSuccess: () => { - setMessage( - "A new verification email has been sent to your email address.", - ); - }, - onError: (error) => { - setMessage(error.message || "Failed to resend verification email."); - }, - }); + const resendEmailMutation = useMutation( + api.users.resendVerificationEmail.mutationOptions({ + onSuccess: () => { + setMessage( + "A new verification email has been sent to your email address.", + ); + }, + onError: (error) => { + setMessage(error.message || "Failed to resend verification email."); + }, + }), + ); + + const isMobileRedirect = isMobileAppRedirect(redirectUrl); useEffect(() => { if (token && email) { @@ -63,12 +88,18 @@ export default function VerifyEmailPage() { const handleResendEmail = () => { if (email) { - resendEmailMutation.mutate({ email }); + resendEmailMutation.mutate({ email, redirectUrl }); } }; const handleSignIn = () => { - router.push("/signin"); + if (isMobileRedirect) { + window.location.href = redirectUrl; + } else if (redirectUrl !== "/") { + router.push(`/signin?redirectUrl=${encodeURIComponent(redirectUrl)}`); + } else { + router.push("/signin"); + } }; return ( @@ -102,7 +133,7 @@ export default function VerifyEmailPage() { </AlertDescription> </Alert> <Button onClick={handleSignIn} className="w-full"> - Sign In + {isMobileRedirect ? "Open App" : "Sign In"} </Button> </> )} diff --git a/apps/web/components/admin/AddUserDialog.tsx b/apps/web/components/admin/AddUserDialog.tsx index 67c38501..b5843eab 100644 --- a/apps/web/components/admin/AddUserDialog.tsx +++ b/apps/web/components/admin/AddUserDialog.tsx @@ -1,213 +1,217 @@ -import { useEffect, useState } from "react";
-import { ActionButton } from "@/components/ui/action-button";
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-import { Input } from "@/components/ui/input";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { toast } from "@/components/ui/use-toast";
-import { api } from "@/lib/trpc";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { TRPCClientError } from "@trpc/client";
-import { useForm } from "react-hook-form";
-import { z } from "zod";
-
-import { zAdminCreateUserSchema } from "@karakeep/shared/types/admin";
-
-type AdminCreateUserSchema = z.infer<typeof zAdminCreateUserSchema>;
-
-export default function AddUserDialog({
- children,
-}: {
- children?: React.ReactNode;
-}) {
- const apiUtils = api.useUtils();
- const [isOpen, onOpenChange] = useState(false);
- const form = useForm<AdminCreateUserSchema>({
- resolver: zodResolver(zAdminCreateUserSchema),
- defaultValues: {
- name: "",
- email: "",
- password: "",
- confirmPassword: "",
- role: "user",
- },
- });
- const { mutate, isPending } = api.admin.createUser.useMutation({
- onSuccess: () => {
- toast({
- description: "User created successfully",
- });
- onOpenChange(false);
- apiUtils.users.list.invalidate();
- apiUtils.admin.userStats.invalidate();
- },
- onError: (error) => {
- if (error instanceof TRPCClientError) {
- toast({
- variant: "destructive",
- description: error.message,
- });
- } else {
- toast({
- variant: "destructive",
- description: "Failed to create user",
- });
- }
- },
- });
-
- useEffect(() => {
- if (!isOpen) {
- form.reset();
- }
- }, [isOpen, form]);
-
- return (
- <Dialog open={isOpen} onOpenChange={onOpenChange}>
- <DialogTrigger asChild>{children}</DialogTrigger>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Add User</DialogTitle>
- </DialogHeader>
- <Form {...form}>
- <form onSubmit={form.handleSubmit((val) => mutate(val))}>
- <div className="flex w-full flex-col space-y-2">
- <FormField
- control={form.control}
- name="name"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Name</FormLabel>
- <FormControl>
- <Input
- type="text"
- placeholder="Name"
- {...field}
- className="w-full rounded border p-2"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="email"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Email</FormLabel>
- <FormControl>
- <Input
- type="email"
- placeholder="Email"
- {...field}
- className="w-full rounded border p-2"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="password"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Password</FormLabel>
- <FormControl>
- <Input
- type="password"
- placeholder="Password"
- {...field}
- className="w-full rounded border p-2"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="confirmPassword"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Confirm Password</FormLabel>
- <FormControl>
- <Input
- type="password"
- placeholder="Confirm Password"
- {...field}
- className="w-full rounded border p-2"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="role"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Role</FormLabel>
- <FormControl>
- <Select
- value={field.value}
- onValueChange={field.onChange}
- >
- <SelectTrigger className="w-full">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="user">User</SelectItem>
- <SelectItem value="admin">Admin</SelectItem>
- </SelectContent>
- </Select>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <DialogFooter className="sm:justify-end">
- <DialogClose asChild>
- <Button type="button" variant="secondary">
- Close
- </Button>
- </DialogClose>
- <ActionButton
- type="submit"
- loading={isPending}
- disabled={isPending}
- >
- Create
- </ActionButton>
- </DialogFooter>
- </div>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- );
-}
+import { useEffect, useState } from "react"; +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "@/components/ui/sonner"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { TRPCClientError } from "@trpc/client"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; +import { zAdminCreateUserSchema } from "@karakeep/shared/types/admin"; + +type AdminCreateUserSchema = z.infer<typeof zAdminCreateUserSchema>; + +export default function AddUserDialog({ + children, +}: { + children?: React.ReactNode; +}) { + const api = useTRPC(); + const queryClient = useQueryClient(); + const [isOpen, onOpenChange] = useState(false); + const form = useForm<AdminCreateUserSchema>({ + resolver: zodResolver(zAdminCreateUserSchema), + defaultValues: { + name: "", + email: "", + password: "", + confirmPassword: "", + role: "user", + }, + }); + const { mutate, isPending } = useMutation( + api.admin.createUser.mutationOptions({ + onSuccess: () => { + toast({ + description: "User created successfully", + }); + onOpenChange(false); + queryClient.invalidateQueries(api.users.list.pathFilter()); + queryClient.invalidateQueries(api.admin.userStats.pathFilter()); + }, + onError: (error) => { + if (error instanceof TRPCClientError) { + toast({ + variant: "destructive", + description: error.message, + }); + } else { + toast({ + variant: "destructive", + description: "Failed to create user", + }); + } + }, + }), + ); + + useEffect(() => { + if (!isOpen) { + form.reset(); + } + }, [isOpen, form]); + + return ( + <Dialog open={isOpen} onOpenChange={onOpenChange}> + <DialogTrigger asChild>{children}</DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle>Add User</DialogTitle> + </DialogHeader> + <Form {...form}> + <form onSubmit={form.handleSubmit((val) => mutate(val))}> + <div className="flex w-full flex-col space-y-2"> + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>Name</FormLabel> + <FormControl> + <Input + type="text" + placeholder="Name" + {...field} + className="w-full rounded border p-2" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel>Email</FormLabel> + <FormControl> + <Input + type="email" + placeholder="Email" + {...field} + className="w-full rounded border p-2" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="password" + render={({ field }) => ( + <FormItem> + <FormLabel>Password</FormLabel> + <FormControl> + <Input + type="password" + placeholder="Password" + {...field} + className="w-full rounded border p-2" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="confirmPassword" + render={({ field }) => ( + <FormItem> + <FormLabel>Confirm Password</FormLabel> + <FormControl> + <Input + type="password" + placeholder="Confirm Password" + {...field} + className="w-full rounded border p-2" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="role" + render={({ field }) => ( + <FormItem> + <FormLabel>Role</FormLabel> + <FormControl> + <Select + value={field.value} + onValueChange={field.onChange} + > + <SelectTrigger className="w-full"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="user">User</SelectItem> + <SelectItem value="admin">Admin</SelectItem> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <DialogFooter className="sm:justify-end"> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton + type="submit" + loading={isPending} + disabled={isPending} + > + Create + </ActionButton> + </DialogFooter> + </div> + </form> + </Form> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/components/admin/AdminNotices.tsx b/apps/web/components/admin/AdminNotices.tsx index 77b1b481..76c3df04 100644 --- a/apps/web/components/admin/AdminNotices.tsx +++ b/apps/web/components/admin/AdminNotices.tsx @@ -2,9 +2,11 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; import { AlertCircle } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + import { AdminCard } from "./AdminCard"; interface AdminNotice { @@ -14,7 +16,8 @@ interface AdminNotice { } function useAdminNotices() { - const { data } = api.admin.getAdminNoticies.useQuery(); + const api = useTRPC(); + const { data } = useQuery(api.admin.getAdminNoticies.queryOptions()); if (!data) { return []; } diff --git a/apps/web/components/admin/BackgroundJobs.tsx b/apps/web/components/admin/BackgroundJobs.tsx index ba73db2e..0df34cc4 100644 --- a/apps/web/components/admin/BackgroundJobs.tsx +++ b/apps/web/components/admin/BackgroundJobs.tsx @@ -11,10 +11,9 @@ import { CardTitle, } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; -import { keepPreviousData } from "@tanstack/react-query"; +import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; import { Activity, AlertTriangle, @@ -31,6 +30,8 @@ import { Webhook, } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + import { Button } from "../ui/button"; import { AdminCard } from "./AdminCard"; @@ -254,13 +255,51 @@ function JobCard({ } function useJobActions() { + const api = useTRPC(); const { t } = useTranslation(); const { mutateAsync: recrawlLinks, isPending: isRecrawlPending } = - api.admin.recrawlLinks.useMutation({ + useMutation( + api.admin.recrawlLinks.mutationOptions({ + onSuccess: () => { + toast({ + description: "Recrawl enqueued", + }); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: e.message, + }); + }, + }), + ); + + const { mutateAsync: reindexBookmarks, isPending: isReindexPending } = + useMutation( + api.admin.reindexAllBookmarks.mutationOptions({ + onSuccess: () => { + toast({ + description: "Reindex enqueued", + }); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: e.message, + }); + }, + }), + ); + + const { + mutateAsync: reprocessAssetsFixMode, + isPending: isReprocessingPending, + } = useMutation( + api.admin.reprocessAssetsFixMode.mutationOptions({ onSuccess: () => { toast({ - description: "Recrawl enqueued", + description: "Reprocessing enqueued", }); }, onError: (e) => { @@ -269,13 +308,17 @@ function useJobActions() { description: e.message, }); }, - }); + }), + ); - const { mutateAsync: reindexBookmarks, isPending: isReindexPending } = - api.admin.reindexAllBookmarks.useMutation({ + const { + mutateAsync: reRunInferenceOnAllBookmarks, + isPending: isInferencePending, + } = useMutation( + api.admin.reRunInferenceOnAllBookmarks.mutationOptions({ onSuccess: () => { toast({ - description: "Reindex enqueued", + description: "Inference jobs enqueued", }); }, onError: (e) => { @@ -284,62 +327,38 @@ function useJobActions() { description: e.message, }); }, - }); - - const { - mutateAsync: reprocessAssetsFixMode, - isPending: isReprocessingPending, - } = api.admin.reprocessAssetsFixMode.useMutation({ - onSuccess: () => { - toast({ - description: "Reprocessing enqueued", - }); - }, - onError: (e) => { - toast({ - variant: "destructive", - description: e.message, - }); - }, - }); - - const { - mutateAsync: reRunInferenceOnAllBookmarks, - isPending: isInferencePending, - } = api.admin.reRunInferenceOnAllBookmarks.useMutation({ - onSuccess: () => { - toast({ - description: "Inference jobs enqueued", - }); - }, - onError: (e) => { - toast({ - variant: "destructive", - description: e.message, - }); - }, - }); + }), + ); const { mutateAsync: runAdminMaintenanceTask, isPending: isAdminMaintenancePending, - } = api.admin.runAdminMaintenanceTask.useMutation({ - onSuccess: () => { - toast({ - description: "Admin maintenance request has been enqueued!", - }); - }, - onError: (e) => { - toast({ - variant: "destructive", - description: e.message, - }); - }, - }); + } = useMutation( + api.admin.runAdminMaintenanceTask.mutationOptions({ + onSuccess: () => { + toast({ + description: "Admin maintenance request has been enqueued!", + }); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: e.message, + }); + }, + }), + ); return { crawlActions: [ { + label: t("admin.background_jobs.actions.recrawl_pending_links_only"), + onClick: () => + recrawlLinks({ crawlStatus: "pending", runInference: true }), + variant: "secondary" as const, + loading: isRecrawlPending, + }, + { label: t("admin.background_jobs.actions.recrawl_failed_links_only"), onClick: () => recrawlLinks({ crawlStatus: "failure", runInference: true }), @@ -361,6 +380,15 @@ function useJobActions() { inferenceActions: [ { label: t( + "admin.background_jobs.actions.regenerate_ai_tags_for_pending_bookmarks_only", + ), + onClick: () => + reRunInferenceOnAllBookmarks({ type: "tag", status: "pending" }), + variant: "secondary" as const, + loading: isInferencePending, + }, + { + label: t( "admin.background_jobs.actions.regenerate_ai_tags_for_failed_bookmarks_only", ), onClick: () => @@ -378,6 +406,18 @@ function useJobActions() { }, { label: t( + "admin.background_jobs.actions.regenerate_ai_summaries_for_pending_bookmarks_only", + ), + onClick: () => + reRunInferenceOnAllBookmarks({ + type: "summarize", + status: "pending", + }), + variant: "secondary" as const, + loading: isInferencePending, + }, + { + label: t( "admin.background_jobs.actions.regenerate_ai_summaries_for_failed_bookmarks_only", ), onClick: () => @@ -438,13 +478,13 @@ function useJobActions() { } export default function BackgroundJobs() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: serverStats } = api.admin.backgroundJobsStats.useQuery( - undefined, - { + const { data: serverStats } = useQuery( + api.admin.backgroundJobsStats.queryOptions(undefined, { refetchInterval: 1000, placeholderData: keepPreviousData, - }, + }), ); const actions = useJobActions(); diff --git a/apps/web/components/admin/BasicStats.tsx b/apps/web/components/admin/BasicStats.tsx index 67352f66..ec2b73a9 100644 --- a/apps/web/components/admin/BasicStats.tsx +++ b/apps/web/components/admin/BasicStats.tsx @@ -3,9 +3,10 @@ import { AdminCard } from "@/components/admin/AdminCard"; import { useClientConfig } from "@/lib/clientConfig"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { useQuery } from "@tanstack/react-query"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + const REPO_LATEST_RELEASE_API = "https://api.github.com/repos/karakeep-app/karakeep/releases/latest"; const REPO_RELEASE_PAGE = "https://github.com/karakeep-app/karakeep/releases"; @@ -42,7 +43,7 @@ function ReleaseInfo() { rel="noreferrer" title="Update available" > - ({latestRelease} ⬆️) + ({latestRelease}⬆️) </a> ); } @@ -71,10 +72,13 @@ function StatsSkeleton() { } export default function BasicStats() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: serverStats } = api.admin.stats.useQuery(undefined, { - refetchInterval: 5000, - }); + const { data: serverStats } = useQuery( + api.admin.stats.queryOptions(undefined, { + refetchInterval: 5000, + }), + ); if (!serverStats) { return <StatsSkeleton />; diff --git a/apps/web/components/admin/BookmarkDebugger.tsx b/apps/web/components/admin/BookmarkDebugger.tsx new file mode 100644 index 00000000..7e15262f --- /dev/null +++ b/apps/web/components/admin/BookmarkDebugger.tsx @@ -0,0 +1,661 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { AdminCard } from "@/components/admin/AdminCard"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import InfoTooltip from "@/components/ui/info-tooltip"; +import { Input } from "@/components/ui/input"; +import { useTranslation } from "@/lib/i18n/client"; +import { formatBytes } from "@/lib/utils"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { formatDistanceToNow } from "date-fns"; +import { + AlertCircle, + CheckCircle2, + ChevronDown, + ChevronRight, + Clock, + Database, + ExternalLink, + FileText, + FileType, + Image as ImageIcon, + Link as LinkIcon, + Loader2, + RefreshCw, + Search, + Sparkles, + Tag, + User, + XCircle, +} from "lucide-react"; +import { parseAsString, useQueryState } from "nuqs"; +import { toast } from "sonner"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; +import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; + +export default function BookmarkDebugger() { + const api = useTRPC(); + const { t } = useTranslation(); + const [inputValue, setInputValue] = useState(""); + const [bookmarkId, setBookmarkId] = useQueryState( + "bookmarkId", + parseAsString.withDefault(""), + ); + const [showHtmlPreview, setShowHtmlPreview] = useState(false); + + // Sync input value with URL on mount/change + useEffect(() => { + if (bookmarkId) { + setInputValue(bookmarkId); + } + }, [bookmarkId]); + + const { + data: debugInfo, + isLoading, + error, + } = useQuery( + api.admin.getBookmarkDebugInfo.queryOptions( + { bookmarkId: bookmarkId }, + { enabled: !!bookmarkId && bookmarkId.length > 0 }, + ), + ); + + const handleLookup = () => { + if (inputValue.trim()) { + setBookmarkId(inputValue.trim()); + } + }; + + const recrawlMutation = useMutation( + api.admin.adminRecrawlBookmark.mutationOptions({ + onSuccess: () => { + toast.success(t("admin.admin_tools.action_success"), { + description: t("admin.admin_tools.recrawl_queued"), + }); + }, + onError: (error) => { + toast.error(t("admin.admin_tools.action_failed"), { + description: error.message, + }); + }, + }), + ); + + const reindexMutation = useMutation( + api.admin.adminReindexBookmark.mutationOptions({ + onSuccess: () => { + toast.success(t("admin.admin_tools.action_success"), { + description: t("admin.admin_tools.reindex_queued"), + }); + }, + onError: (error) => { + toast.error(t("admin.admin_tools.action_failed"), { + description: error.message, + }); + }, + }), + ); + + const retagMutation = useMutation( + api.admin.adminRetagBookmark.mutationOptions({ + onSuccess: () => { + toast.success(t("admin.admin_tools.action_success"), { + description: t("admin.admin_tools.retag_queued"), + }); + }, + onError: (error) => { + toast.error(t("admin.admin_tools.action_failed"), { + description: error.message, + }); + }, + }), + ); + + const resummarizeMutation = useMutation( + api.admin.adminResummarizeBookmark.mutationOptions({ + onSuccess: () => { + toast.success(t("admin.admin_tools.action_success"), { + description: t("admin.admin_tools.resummarize_queued"), + }); + }, + onError: (error) => { + toast.error(t("admin.admin_tools.action_failed"), { + description: error.message, + }); + }, + }), + ); + + const handleRecrawl = () => { + if (bookmarkId) { + recrawlMutation.mutate({ bookmarkId }); + } + }; + + const handleReindex = () => { + if (bookmarkId) { + reindexMutation.mutate({ bookmarkId }); + } + }; + + const handleRetag = () => { + if (bookmarkId) { + retagMutation.mutate({ bookmarkId }); + } + }; + + const handleResummarize = () => { + if (bookmarkId) { + resummarizeMutation.mutate({ bookmarkId }); + } + }; + + const getStatusBadge = (status: "pending" | "failure" | "success" | null) => { + if (!status) return null; + + const config = { + success: { + variant: "default" as const, + icon: CheckCircle2, + }, + failure: { + variant: "destructive" as const, + icon: XCircle, + }, + pending: { + variant: "secondary" as const, + icon: AlertCircle, + }, + }; + + const { variant, icon: Icon } = config[status]; + + return ( + <Badge variant={variant}> + <Icon className="mr-1 h-3 w-3" /> + {status} + </Badge> + ); + }; + + return ( + <div className="flex flex-col gap-4"> + {/* Input Section */} + <AdminCard> + <div className="mb-3 flex items-center gap-2"> + <Search className="h-5 w-5 text-muted-foreground" /> + <h2 className="text-lg font-semibold"> + {t("admin.admin_tools.bookmark_debugger")} + </h2> + <InfoTooltip className="text-muted-foreground" size={16}> + Some data will be redacted for privacy. + </InfoTooltip> + </div> + <div className="flex gap-2"> + <div className="relative max-w-md flex-1"> + <Database className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> + <Input + placeholder={t("admin.admin_tools.bookmark_id_placeholder")} + value={inputValue} + onChange={(e) => setInputValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleLookup(); + } + }} + className="pl-9" + /> + </div> + <Button onClick={handleLookup} disabled={!inputValue.trim()}> + <Search className="mr-2 h-4 w-4" /> + {t("admin.admin_tools.lookup")} + </Button> + </div> + </AdminCard> + + {/* Loading State */} + {isLoading && ( + <AdminCard> + <div className="flex items-center justify-center py-8"> + <Loader2 className="h-8 w-8 animate-spin text-gray-400" /> + </div> + </AdminCard> + )} + + {/* Error State */} + {!isLoading && error && ( + <AdminCard> + <div className="flex items-center gap-3 rounded-lg border border-destructive/50 bg-destructive/10 p-4"> + <XCircle className="h-5 w-5 flex-shrink-0 text-destructive" /> + <div className="flex-1"> + <h3 className="text-sm font-semibold text-destructive"> + {t("admin.admin_tools.fetch_error")} + </h3> + <p className="mt-1 text-sm text-muted-foreground"> + {error.message} + </p> + </div> + </div> + </AdminCard> + )} + + {/* Debug Info Display */} + {!isLoading && !error && debugInfo && ( + <AdminCard> + <div className="space-y-4"> + {/* Basic Info & Status */} + <div className="grid gap-4 md:grid-cols-2"> + {/* Basic Info */} + <div className="rounded-lg border bg-muted/30 p-4"> + <div className="mb-3 flex items-center gap-2"> + <Database className="h-4 w-4 text-muted-foreground" /> + <h3 className="text-sm font-semibold"> + {t("admin.admin_tools.basic_info")} + </h3> + </div> + <div className="space-y-2.5 text-sm"> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <Database className="h-3.5 w-3.5" /> + {t("common.id")} + </span> + <span className="font-mono text-xs">{debugInfo.id}</span> + </div> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <FileType className="h-3.5 w-3.5" /> + {t("common.type")} + </span> + <Badge variant="secondary">{debugInfo.type}</Badge> + </div> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <LinkIcon className="h-3.5 w-3.5" /> + {t("common.source")} + </span> + <span>{debugInfo.source || "N/A"}</span> + </div> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <User className="h-3.5 w-3.5" /> + {t("admin.admin_tools.owner_user_id")} + </span> + <span className="font-mono text-xs"> + {debugInfo.userId} + </span> + </div> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <Clock className="h-3.5 w-3.5" /> + {t("common.created_at")} + </span> + <span className="text-xs"> + {formatDistanceToNow(new Date(debugInfo.createdAt), { + addSuffix: true, + })} + </span> + </div> + {debugInfo.modifiedAt && ( + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <Clock className="h-3.5 w-3.5" /> + {t("common.updated_at")} + </span> + <span className="text-xs"> + {formatDistanceToNow(new Date(debugInfo.modifiedAt), { + addSuffix: true, + })} + </span> + </div> + )} + </div> + </div> + + {/* Status */} + <div className="rounded-lg border bg-muted/30 p-4"> + <div className="mb-3 flex items-center gap-2"> + <AlertCircle className="h-4 w-4 text-muted-foreground" /> + <h3 className="text-sm font-semibold"> + {t("admin.admin_tools.status")} + </h3> + </div> + <div className="space-y-2.5 text-sm"> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <Tag className="h-3.5 w-3.5" /> + {t("admin.admin_tools.tagging_status")} + </span> + {getStatusBadge(debugInfo.taggingStatus)} + </div> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <Sparkles className="h-3.5 w-3.5" /> + {t("admin.admin_tools.summarization_status")} + </span> + {getStatusBadge(debugInfo.summarizationStatus)} + </div> + {debugInfo.linkInfo && ( + <> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <RefreshCw className="h-3.5 w-3.5" /> + {t("admin.admin_tools.crawl_status")} + </span> + {getStatusBadge(debugInfo.linkInfo.crawlStatus)} + </div> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <LinkIcon className="h-3.5 w-3.5" /> + {t("admin.admin_tools.crawl_status_code")} + </span> + <Badge + variant={ + debugInfo.linkInfo.crawlStatusCode === null || + (debugInfo.linkInfo.crawlStatusCode >= 200 && + debugInfo.linkInfo.crawlStatusCode < 300) + ? "default" + : "destructive" + } + > + {debugInfo.linkInfo.crawlStatusCode} + </Badge> + </div> + {debugInfo.linkInfo.crawledAt && ( + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <Clock className="h-3.5 w-3.5" /> + {t("admin.admin_tools.crawled_at")} + </span> + <span className="text-xs"> + {formatDistanceToNow( + new Date(debugInfo.linkInfo.crawledAt), + { + addSuffix: true, + }, + )} + </span> + </div> + )} + </> + )} + </div> + </div> + </div> + + {/* Content */} + {(debugInfo.title || + debugInfo.summary || + debugInfo.linkInfo || + debugInfo.textInfo?.sourceUrl || + debugInfo.assetInfo) && ( + <div className="rounded-lg border bg-muted/30 p-4"> + <div className="mb-3 flex items-center gap-2"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <h3 className="text-sm font-semibold"> + {t("admin.admin_tools.content")} + </h3> + </div> + <div className="space-y-3 text-sm"> + {debugInfo.title && ( + <div> + <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> + <FileText className="h-3.5 w-3.5" /> + {t("common.title")} + </div> + <div className="rounded border bg-background px-3 py-2 font-medium"> + {debugInfo.title} + </div> + </div> + )} + {debugInfo.summary && ( + <div> + <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> + <Sparkles className="h-3.5 w-3.5" /> + {t("admin.admin_tools.summary")} + </div> + <div className="rounded border bg-background px-3 py-2"> + {debugInfo.summary} + </div> + </div> + )} + {debugInfo.linkInfo && ( + <div> + <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> + <LinkIcon className="h-3.5 w-3.5" /> + {t("admin.admin_tools.url")} + </div> + <Link + prefetch={false} + href={debugInfo.linkInfo.url} + target="_blank" + rel="noopener noreferrer" + className="flex items-center gap-1.5 rounded border bg-background px-3 py-2 text-primary hover:underline" + > + <span className="break-all"> + {debugInfo.linkInfo.url} + </span> + <ExternalLink className="h-3.5 w-3.5 flex-shrink-0" /> + </Link> + </div> + )} + {debugInfo.textInfo?.sourceUrl && ( + <div> + <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> + <LinkIcon className="h-3.5 w-3.5" /> + {t("admin.admin_tools.source_url")} + </div> + <Link + prefetch={false} + href={debugInfo.textInfo.sourceUrl} + target="_blank" + rel="noopener noreferrer" + className="flex items-center gap-1.5 rounded border bg-background px-3 py-2 text-primary hover:underline" + > + <span className="break-all"> + {debugInfo.textInfo.sourceUrl} + </span> + <ExternalLink className="h-3.5 w-3.5 flex-shrink-0" /> + </Link> + </div> + )} + {debugInfo.assetInfo && ( + <div> + <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> + <ImageIcon className="h-3.5 w-3.5" /> + {t("admin.admin_tools.asset_type")} + </div> + <div className="rounded border bg-background px-3 py-2"> + <Badge variant="secondary" className="mb-1"> + {debugInfo.assetInfo.assetType} + </Badge> + {debugInfo.assetInfo.fileName && ( + <div className="mt-1 font-mono text-xs text-muted-foreground"> + {debugInfo.assetInfo.fileName} + </div> + )} + </div> + </div> + )} + </div> + </div> + )} + + {/* HTML Preview */} + {debugInfo.linkInfo && debugInfo.linkInfo.htmlContentPreview && ( + <div className="rounded-lg border bg-muted/30 p-4"> + <button + onClick={() => setShowHtmlPreview(!showHtmlPreview)} + className="flex w-full items-center gap-2 text-sm font-semibold hover:opacity-70" + > + {showHtmlPreview ? ( + <ChevronDown className="h-4 w-4 text-muted-foreground" /> + ) : ( + <ChevronRight className="h-4 w-4 text-muted-foreground" /> + )} + <FileText className="h-4 w-4 text-muted-foreground" /> + {t("admin.admin_tools.html_preview")} + </button> + {showHtmlPreview && ( + <pre className="mt-3 max-h-60 overflow-auto rounded-md border bg-muted p-3 text-xs"> + {debugInfo.linkInfo.htmlContentPreview} + </pre> + )} + </div> + )} + + {/* Tags */} + {debugInfo.tags.length > 0 && ( + <div className="rounded-lg border bg-muted/30 p-4"> + <div className="mb-3 flex items-center gap-2"> + <Tag className="h-4 w-4 text-muted-foreground" /> + <h3 className="text-sm font-semibold"> + {t("common.tags")}{" "} + <span className="text-muted-foreground"> + ({debugInfo.tags.length}) + </span> + </h3> + </div> + <div className="flex flex-wrap gap-2"> + {debugInfo.tags.map((tag) => ( + <Badge + key={tag.id} + variant={ + tag.attachedBy === "ai" ? "default" : "secondary" + } + className="gap-1.5" + > + {tag.attachedBy === "ai" && ( + <Sparkles className="h-3 w-3" /> + )} + <span>{tag.name}</span> + </Badge> + ))} + </div> + </div> + )} + + {/* Assets */} + {debugInfo.assets.length > 0 && ( + <div className="rounded-lg border bg-muted/30 p-4"> + <div className="mb-3 flex items-center gap-2"> + <ImageIcon className="h-4 w-4 text-muted-foreground" /> + <h3 className="text-sm font-semibold"> + {t("common.attachments")}{" "} + <span className="text-muted-foreground"> + ({debugInfo.assets.length}) + </span> + </h3> + </div> + <div className="space-y-2 text-sm"> + {debugInfo.assets.map((asset) => ( + <div + key={asset.id} + className="flex items-center justify-between rounded-md border bg-background p-3" + > + <div className="flex items-center gap-3"> + <ImageIcon className="h-4 w-4 text-muted-foreground" /> + <div> + <Badge variant="secondary" className="text-xs"> + {asset.assetType} + </Badge> + <div className="mt-1 text-xs text-muted-foreground"> + {formatBytes(asset.size)} + </div> + </div> + </div> + {asset.url && ( + <Link + prefetch={false} + href={asset.url} + target="_blank" + rel="noopener noreferrer" + className="flex items-center gap-1.5 text-primary hover:underline" + > + {t("admin.admin_tools.view")} + <ExternalLink className="h-3.5 w-3.5" /> + </Link> + )} + </div> + ))} + </div> + </div> + )} + + {/* Actions */} + <div className="rounded-lg border border-dashed bg-muted/20 p-4"> + <div className="mb-3 flex items-center gap-2"> + <RefreshCw className="h-4 w-4 text-muted-foreground" /> + <h3 className="text-sm font-semibold">{t("common.actions")}</h3> + </div> + <div className="flex flex-wrap gap-2"> + <Button + onClick={handleRecrawl} + disabled={ + debugInfo.type !== BookmarkTypes.LINK || + recrawlMutation.isPending + } + size="sm" + variant="outline" + > + {recrawlMutation.isPending ? ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <RefreshCw className="mr-2 h-4 w-4" /> + )} + {t("admin.admin_tools.recrawl")} + </Button> + <Button + onClick={handleReindex} + disabled={reindexMutation.isPending} + size="sm" + variant="outline" + > + {reindexMutation.isPending ? ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <Search className="mr-2 h-4 w-4" /> + )} + {t("admin.admin_tools.reindex")} + </Button> + <Button + onClick={handleRetag} + disabled={retagMutation.isPending} + size="sm" + variant="outline" + > + {retagMutation.isPending ? ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <Tag className="mr-2 h-4 w-4" /> + )} + {t("admin.admin_tools.retag")} + </Button> + <Button + onClick={handleResummarize} + disabled={ + debugInfo.type !== BookmarkTypes.LINK || + resummarizeMutation.isPending + } + size="sm" + variant="outline" + > + {resummarizeMutation.isPending ? ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <Sparkles className="mr-2 h-4 w-4" /> + )} + {t("admin.admin_tools.resummarize")} + </Button> + </div> + </div> + </div> + </AdminCard> + )} + </div> + ); +} diff --git a/apps/web/components/admin/CreateInviteDialog.tsx b/apps/web/components/admin/CreateInviteDialog.tsx index 84f5c60f..e9930b1e 100644 --- a/apps/web/components/admin/CreateInviteDialog.tsx +++ b/apps/web/components/admin/CreateInviteDialog.tsx @@ -19,13 +19,15 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { toast } from "@/components/ui/use-toast"; -import { api } from "@/lib/trpc"; +import { toast } from "@/components/ui/sonner"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { TRPCClientError } from "@trpc/client"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + const createInviteSchema = z.object({ email: z.string().email("Please enter a valid email address"), }); @@ -37,6 +39,8 @@ interface CreateInviteDialogProps { export default function CreateInviteDialog({ children, }: CreateInviteDialogProps) { + const api = useTRPC(); + const queryClient = useQueryClient(); const [open, setOpen] = useState(false); const [errorMessage, setErrorMessage] = useState(""); @@ -47,25 +51,26 @@ export default function CreateInviteDialog({ }, }); - const invalidateInvitesList = api.useUtils().invites.list.invalidate; - const createInviteMutation = api.invites.create.useMutation({ - onSuccess: () => { - toast({ - description: "Invite sent successfully", - }); - invalidateInvitesList(); - setOpen(false); - form.reset(); - setErrorMessage(""); - }, - onError: (e) => { - if (e instanceof TRPCClientError) { - setErrorMessage(e.message); - } else { - setErrorMessage("Failed to send invite"); - } - }, - }); + const createInviteMutation = useMutation( + api.invites.create.mutationOptions({ + onSuccess: () => { + toast({ + description: "Invite sent successfully", + }); + queryClient.invalidateQueries(api.invites.list.pathFilter()); + setOpen(false); + form.reset(); + setErrorMessage(""); + }, + onError: (e) => { + if (e instanceof TRPCClientError) { + setErrorMessage(e.message); + } else { + setErrorMessage("Failed to send invite"); + } + }, + }), + ); return ( <Dialog open={open} onOpenChange={setOpen}> diff --git a/apps/web/components/admin/InvitesList.tsx b/apps/web/components/admin/InvitesList.tsx index 1418c9bb..d4dc1793 100644 --- a/apps/web/components/admin/InvitesList.tsx +++ b/apps/web/components/admin/InvitesList.tsx @@ -2,7 +2,7 @@ import { ActionButton } from "@/components/ui/action-button"; import { ButtonWithTooltip } from "@/components/ui/button"; -import LoadingSpinner from "@/components/ui/spinner"; +import { toast } from "@/components/ui/sonner"; import { Table, TableBody, @@ -11,25 +11,32 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { toast } from "@/components/ui/use-toast"; -import { api } from "@/lib/trpc"; +import { + useMutation, + useQueryClient, + useSuspenseQuery, +} from "@tanstack/react-query"; import { formatDistanceToNow } from "date-fns"; import { Mail, MailX, UserPlus } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + import ActionConfirmingDialog from "../ui/action-confirming-dialog"; +import { AdminCard } from "./AdminCard"; import CreateInviteDialog from "./CreateInviteDialog"; export default function InvitesList() { - const invalidateInvitesList = api.useUtils().invites.list.invalidate; - const { data: invites, isLoading } = api.invites.list.useQuery(); + const api = useTRPC(); + const queryClient = useQueryClient(); + const { data: invites } = useSuspenseQuery(api.invites.list.queryOptions()); - const { mutateAsync: revokeInvite, isPending: isRevokePending } = - api.invites.revoke.useMutation({ + const { mutateAsync: revokeInvite, isPending: isRevokePending } = useMutation( + api.invites.revoke.mutationOptions({ onSuccess: () => { toast({ description: "Invite revoked successfully", }); - invalidateInvitesList(); + queryClient.invalidateQueries(api.invites.list.pathFilter()); }, onError: (e) => { toast({ @@ -37,15 +44,16 @@ export default function InvitesList() { description: `Failed to revoke invite: ${e.message}`, }); }, - }); + }), + ); - const { mutateAsync: resendInvite, isPending: isResendPending } = - api.invites.resend.useMutation({ + const { mutateAsync: resendInvite, isPending: isResendPending } = useMutation( + api.invites.resend.mutationOptions({ onSuccess: () => { toast({ description: "Invite resent successfully", }); - invalidateInvitesList(); + queryClient.invalidateQueries(api.invites.list.pathFilter()); }, onError: (e) => { toast({ @@ -53,11 +61,8 @@ export default function InvitesList() { description: `Failed to resend invite: ${e.message}`, }); }, - }); - - if (isLoading) { - return <LoadingSpinner />; - } + }), + ); const activeInvites = invites?.invites || []; @@ -139,17 +144,19 @@ export default function InvitesList() { ); return ( - <div className="flex flex-col gap-4"> - <div className="mb-2 flex items-center justify-between text-xl font-medium"> - <span>User Invitations ({activeInvites.length})</span> - <CreateInviteDialog> - <ButtonWithTooltip tooltip="Send Invite" variant="outline"> - <UserPlus size={16} /> - </ButtonWithTooltip> - </CreateInviteDialog> - </div> + <AdminCard> + <div className="flex flex-col gap-4"> + <div className="mb-2 flex items-center justify-between text-xl font-medium"> + <span>User Invitations ({activeInvites.length})</span> + <CreateInviteDialog> + <ButtonWithTooltip tooltip="Send Invite" variant="outline"> + <UserPlus size={16} /> + </ButtonWithTooltip> + </CreateInviteDialog> + </div> - <InviteTable invites={activeInvites} title="Invites" /> - </div> + <InviteTable invites={activeInvites} title="Invites" /> + </div> + </AdminCard> ); } diff --git a/apps/web/components/admin/InvitesListSkeleton.tsx b/apps/web/components/admin/InvitesListSkeleton.tsx new file mode 100644 index 00000000..19e8088d --- /dev/null +++ b/apps/web/components/admin/InvitesListSkeleton.tsx @@ -0,0 +1,55 @@ +import { AdminCard } from "@/components/admin/AdminCard"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +const headerWidths = ["w-40", "w-28", "w-20", "w-20"]; + +export default function InvitesListSkeleton() { + return ( + <AdminCard> + <div className="flex flex-col gap-4"> + <div className="mb-2 flex items-center justify-between"> + <Skeleton className="h-6 w-48" /> + <Skeleton className="h-9 w-9" /> + </div> + + <Table> + <TableHeader> + <TableRow> + {headerWidths.map((width, index) => ( + <TableHead key={`invite-list-header-${index}`}> + <Skeleton className={`h-4 ${width}`} /> + </TableHead> + ))} + </TableRow> + </TableHeader> + <TableBody> + {Array.from({ length: 2 }).map((_, rowIndex) => ( + <TableRow key={`invite-list-row-${rowIndex}`}> + {headerWidths.map((width, cellIndex) => ( + <TableCell key={`invite-list-cell-${rowIndex}-${cellIndex}`}> + {cellIndex === headerWidths.length - 1 ? ( + <div className="flex gap-2"> + <Skeleton className="h-6 w-6" /> + <Skeleton className="h-6 w-6" /> + </div> + ) : ( + <Skeleton className={`h-4 ${width}`} /> + )} + </TableCell> + ))} + </TableRow> + ))} + </TableBody> + </Table> + </div> + </AdminCard> + ); +} diff --git a/apps/web/components/admin/ResetPasswordDialog.tsx b/apps/web/components/admin/ResetPasswordDialog.tsx index cc2a95f5..f195395a 100644 --- a/apps/web/components/admin/ResetPasswordDialog.tsx +++ b/apps/web/components/admin/ResetPasswordDialog.tsx @@ -1,145 +1,150 @@ -import { useEffect, useState } from "react";
-import { ActionButton } from "@/components/ui/action-button";
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-import { Input } from "@/components/ui/input";
-import { toast } from "@/components/ui/use-toast";
-import { api } from "@/lib/trpc"; // Adjust the import path as needed
-import { zodResolver } from "@hookform/resolvers/zod";
-import { TRPCClientError } from "@trpc/client";
-import { useForm } from "react-hook-form";
-import { z } from "zod";
-
-import { resetPasswordSchema } from "@karakeep/shared/types/admin";
-
-interface ResetPasswordDialogProps {
- userId: string;
- children?: React.ReactNode;
-}
-
-type ResetPasswordSchema = z.infer<typeof resetPasswordSchema>;
-
-export default function ResetPasswordDialog({
- children,
- userId,
-}: ResetPasswordDialogProps) {
- const [isOpen, onOpenChange] = useState(false);
- const form = useForm<ResetPasswordSchema>({
- resolver: zodResolver(resetPasswordSchema),
- defaultValues: {
- userId,
- newPassword: "",
- newPasswordConfirm: "",
- },
- });
- const { mutate, isPending } = api.admin.resetPassword.useMutation({
- onSuccess: () => {
- toast({
- description: "Password reset successfully",
- });
- onOpenChange(false);
- },
- onError: (error) => {
- if (error instanceof TRPCClientError) {
- toast({
- variant: "destructive",
- description: error.message,
- });
- } else {
- toast({
- variant: "destructive",
- description: "Failed to reset password",
- });
- }
- },
- });
-
- useEffect(() => {
- if (isOpen) {
- form.reset();
- }
- }, [isOpen, form]);
-
- return (
- <Dialog open={isOpen} onOpenChange={onOpenChange}>
- <DialogTrigger asChild>{children}</DialogTrigger>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Reset Password</DialogTitle>
- </DialogHeader>
- <Form {...form}>
- <form onSubmit={form.handleSubmit((val) => mutate(val))}>
- <div className="flex w-full flex-col space-y-2">
- <FormField
- control={form.control}
- name="newPassword"
- render={({ field }) => (
- <FormItem>
- <FormLabel>New Password</FormLabel>
- <FormControl>
- <Input
- type="password"
- placeholder="New Password"
- {...field}
- className="w-full rounded border p-2"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="newPasswordConfirm"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Confirm New Password</FormLabel>
- <FormControl>
- <Input
- type="password"
- placeholder="Confirm New Password"
- {...field}
- className="w-full rounded border p-2"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <DialogFooter className="sm:justify-end">
- <DialogClose asChild>
- <Button type="button" variant="secondary">
- Close
- </Button>
- </DialogClose>
- <ActionButton
- type="submit"
- loading={isPending}
- disabled={isPending}
- >
- Reset
- </ActionButton>
- </DialogFooter>
- </div>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- );
-}
+import { useEffect, useState } from "react"; +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/sonner"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { TRPCClientError } from "@trpc/client"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; // Adjust the import path as needed + +import { resetPasswordSchema } from "@karakeep/shared/types/admin"; + +interface ResetPasswordDialogProps { + userId: string; + children?: React.ReactNode; +} + +type ResetPasswordSchema = z.infer<typeof resetPasswordSchema>; + +export default function ResetPasswordDialog({ + children, + userId, +}: ResetPasswordDialogProps) { + const api = useTRPC(); + const [isOpen, onOpenChange] = useState(false); + const form = useForm<ResetPasswordSchema>({ + resolver: zodResolver(resetPasswordSchema), + defaultValues: { + userId, + newPassword: "", + newPasswordConfirm: "", + }, + }); + const { mutate, isPending } = useMutation( + api.admin.resetPassword.mutationOptions({ + onSuccess: () => { + toast({ + description: "Password reset successfully", + }); + onOpenChange(false); + }, + onError: (error) => { + if (error instanceof TRPCClientError) { + toast({ + variant: "destructive", + description: error.message, + }); + } else { + toast({ + variant: "destructive", + description: "Failed to reset password", + }); + } + }, + }), + ); + + useEffect(() => { + if (isOpen) { + form.reset(); + } + }, [isOpen, form]); + + return ( + <Dialog open={isOpen} onOpenChange={onOpenChange}> + <DialogTrigger asChild>{children}</DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle>Reset Password</DialogTitle> + </DialogHeader> + <Form {...form}> + <form onSubmit={form.handleSubmit((val) => mutate(val))}> + <div className="flex w-full flex-col space-y-2"> + <FormField + control={form.control} + name="newPassword" + render={({ field }) => ( + <FormItem> + <FormLabel>New Password</FormLabel> + <FormControl> + <Input + type="password" + placeholder="New Password" + {...field} + className="w-full rounded border p-2" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="newPasswordConfirm" + render={({ field }) => ( + <FormItem> + <FormLabel>Confirm New Password</FormLabel> + <FormControl> + <Input + type="password" + placeholder="Confirm New Password" + {...field} + className="w-full rounded border p-2" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <DialogFooter className="sm:justify-end"> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton + type="submit" + loading={isPending} + disabled={isPending} + > + Reset + </ActionButton> + </DialogFooter> + </div> + </form> + </Form> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/components/admin/ServiceConnections.tsx b/apps/web/components/admin/ServiceConnections.tsx index 8d79d8bb..5cdab46a 100644 --- a/apps/web/components/admin/ServiceConnections.tsx +++ b/apps/web/components/admin/ServiceConnections.tsx @@ -2,7 +2,9 @@ import { AdminCard } from "@/components/admin/AdminCard"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; function ConnectionStatus({ label, @@ -105,10 +107,13 @@ function ConnectionsSkeleton() { } export default function ServiceConnections() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: connections } = api.admin.checkConnections.useQuery(undefined, { - refetchInterval: 10000, - }); + const { data: connections } = useQuery( + api.admin.checkConnections.queryOptions(undefined, { + refetchInterval: 10000, + }), + ); if (!connections) { return <ConnectionsSkeleton />; diff --git a/apps/web/components/admin/UpdateUserDialog.tsx b/apps/web/components/admin/UpdateUserDialog.tsx index 7093ccda..95ccb6fd 100644 --- a/apps/web/components/admin/UpdateUserDialog.tsx +++ b/apps/web/components/admin/UpdateUserDialog.tsx @@ -26,13 +26,14 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { toast } from "@/components/ui/use-toast"; -import { api } from "@/lib/trpc"; +import { toast } from "@/components/ui/sonner"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { TRPCClientError } from "@trpc/client"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { updateUserSchema } from "@karakeep/shared/types/admin"; type UpdateUserSchema = z.infer<typeof updateUserSchema>; @@ -51,7 +52,8 @@ export default function UpdateUserDialog({ currentStorageQuota, children, }: UpdateUserDialogProps) { - const apiUtils = api.useUtils(); + const api = useTRPC(); + const queryClient = useQueryClient(); const [isOpen, onOpenChange] = useState(false); const defaultValues = { userId, @@ -63,28 +65,30 @@ export default function UpdateUserDialog({ resolver: zodResolver(updateUserSchema), defaultValues, }); - const { mutate, isPending } = api.admin.updateUser.useMutation({ - onSuccess: () => { - toast({ - description: "User updated successfully", - }); - apiUtils.users.list.invalidate(); - onOpenChange(false); - }, - onError: (error) => { - if (error instanceof TRPCClientError) { + const { mutate, isPending } = useMutation( + api.admin.updateUser.mutationOptions({ + onSuccess: () => { toast({ - variant: "destructive", - description: error.message, + description: "User updated successfully", }); - } else { - toast({ - variant: "destructive", - description: "Failed to update user", - }); - } - }, - }); + queryClient.invalidateQueries(api.users.list.pathFilter()); + onOpenChange(false); + }, + onError: (error) => { + if (error instanceof TRPCClientError) { + toast({ + variant: "destructive", + description: error.message, + }); + } else { + toast({ + variant: "destructive", + description: "Failed to update user", + }); + } + }, + }), + ); useEffect(() => { if (isOpen) { diff --git a/apps/web/components/admin/UserList.tsx b/apps/web/components/admin/UserList.tsx index f386a8cd..6789f66a 100644 --- a/apps/web/components/admin/UserList.tsx +++ b/apps/web/components/admin/UserList.tsx @@ -2,7 +2,7 @@ import { ActionButton } from "@/components/ui/action-button"; import { ButtonWithTooltip } from "@/components/ui/button"; -import LoadingSpinner from "@/components/ui/spinner"; +import { toast } from "@/components/ui/sonner"; import { Table, TableBody, @@ -11,16 +11,20 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { toast } from "@/components/ui/use-toast"; +import { useSession } from "@/lib/auth/client"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { + useMutation, + useQueryClient, + useSuspenseQuery, +} from "@tanstack/react-query"; import { Check, KeyRound, Pencil, Trash, UserPlus, X } from "lucide-react"; -import { useSession } from "next-auth/react"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; import ActionConfirmingDialog from "../ui/action-confirming-dialog"; import AddUserDialog from "./AddUserDialog"; import { AdminCard } from "./AdminCard"; -import InvitesList from "./InvitesList"; import ResetPasswordDialog from "./ResetPasswordDialog"; import UpdateUserDialog from "./UpdateUserDialog"; @@ -32,18 +36,23 @@ function toHumanReadableSize(size: number) { } export default function UsersSection() { + const api = useTRPC(); + const queryClient = useQueryClient(); const { t } = useTranslation(); const { data: session } = useSession(); - const invalidateUserList = api.useUtils().users.list.invalidate; - const { data: users } = api.users.list.useQuery(); - const { data: userStats } = api.admin.userStats.useQuery(); - const { mutateAsync: deleteUser, isPending: isDeletionPending } = - api.users.delete.useMutation({ + const { + data: { users }, + } = useSuspenseQuery(api.users.list.queryOptions()); + const { data: userStats } = useSuspenseQuery( + api.admin.userStats.queryOptions(), + ); + const { mutateAsync: deleteUser, isPending: isDeletionPending } = useMutation( + api.users.delete.mutationOptions({ onSuccess: () => { toast({ description: "User deleted", }); - invalidateUserList(); + queryClient.invalidateQueries(api.users.list.pathFilter()); }, onError: (e) => { toast({ @@ -51,122 +60,113 @@ export default function UsersSection() { description: `Something went wrong: ${e.message}`, }); }, - }); - - if (!users || !userStats) { - return <LoadingSpinner />; - } + }), + ); return ( - <div className="flex flex-col gap-4"> - <AdminCard> - <div className="flex flex-col gap-4"> - <div className="mb-2 flex items-center justify-between text-xl font-medium"> - <span>{t("admin.users_list.users_list")}</span> - <AddUserDialog> - <ButtonWithTooltip tooltip="Create User" variant="outline"> - <UserPlus size={16} /> - </ButtonWithTooltip> - </AddUserDialog> - </div> + <AdminCard> + <div className="flex flex-col gap-4"> + <div className="mb-2 flex items-center justify-between text-xl font-medium"> + <span>{t("admin.users_list.users_list")}</span> + <AddUserDialog> + <ButtonWithTooltip tooltip="Create User" variant="outline"> + <UserPlus size={16} /> + </ButtonWithTooltip> + </AddUserDialog> + </div> - <Table> - <TableHeader className="bg-gray-200"> - <TableRow> - <TableHead>{t("common.name")}</TableHead> - <TableHead>{t("common.email")}</TableHead> - <TableHead>{t("admin.users_list.num_bookmarks")}</TableHead> - <TableHead>{t("admin.users_list.asset_sizes")}</TableHead> - <TableHead>{t("common.role")}</TableHead> - <TableHead>{t("admin.users_list.local_user")}</TableHead> - <TableHead>{t("common.actions")}</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {users.users.map((u) => ( - <TableRow key={u.id}> - <TableCell className="py-1">{u.name}</TableCell> - <TableCell className="py-1">{u.email}</TableCell> - <TableCell className="py-1"> - {userStats[u.id].numBookmarks} /{" "} - {u.bookmarkQuota ?? t("admin.users_list.unlimited")} - </TableCell> - <TableCell className="py-1"> - {toHumanReadableSize(userStats[u.id].assetSizes)} /{" "} - {u.storageQuota - ? toHumanReadableSize(u.storageQuota) - : t("admin.users_list.unlimited")} - </TableCell> - <TableCell className="py-1"> - {u.role && t(`common.roles.${u.role}`)} - </TableCell> - <TableCell className="py-1"> - {u.localUser ? <Check /> : <X />} - </TableCell> - <TableCell className="flex gap-1 py-1"> - <ActionConfirmingDialog - title={t("admin.users_list.delete_user")} - description={t( - "admin.users_list.delete_user_confirm_description", - { - name: u.name ?? "this user", - }, - )} - actionButton={(setDialogOpen) => ( - <ActionButton - variant="destructive" - loading={isDeletionPending} - onClick={async () => { - await deleteUser({ userId: u.id }); - setDialogOpen(false); - }} - > - Delete - </ActionButton> - )} - > - <ButtonWithTooltip - tooltip={t("admin.users_list.delete_user")} - variant="outline" - disabled={session!.user.id == u.id} + <Table> + <TableHeader className="bg-gray-200"> + <TableRow> + <TableHead>{t("common.name")}</TableHead> + <TableHead>{t("common.email")}</TableHead> + <TableHead>{t("admin.users_list.num_bookmarks")}</TableHead> + <TableHead>{t("admin.users_list.asset_sizes")}</TableHead> + <TableHead>{t("common.role")}</TableHead> + <TableHead>{t("admin.users_list.local_user")}</TableHead> + <TableHead>{t("common.actions")}</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {users.map((u) => ( + <TableRow key={u.id}> + <TableCell className="py-1">{u.name}</TableCell> + <TableCell className="py-1">{u.email}</TableCell> + <TableCell className="py-1"> + {userStats[u.id].numBookmarks} /{" "} + {u.bookmarkQuota ?? t("admin.users_list.unlimited")} + </TableCell> + <TableCell className="py-1"> + {toHumanReadableSize(userStats[u.id].assetSizes)} /{" "} + {u.storageQuota + ? toHumanReadableSize(u.storageQuota) + : t("admin.users_list.unlimited")} + </TableCell> + <TableCell className="py-1"> + {u.role && t(`common.roles.${u.role}`)} + </TableCell> + <TableCell className="py-1"> + {u.localUser ? <Check /> : <X />} + </TableCell> + <TableCell className="flex gap-1 py-1"> + <ActionConfirmingDialog + title={t("admin.users_list.delete_user")} + description={t( + "admin.users_list.delete_user_confirm_description", + { + name: u.name ?? "this user", + }, + )} + actionButton={(setDialogOpen) => ( + <ActionButton + variant="destructive" + loading={isDeletionPending} + onClick={async () => { + await deleteUser({ userId: u.id }); + setDialogOpen(false); + }} > - <Trash size={16} color="red" /> - </ButtonWithTooltip> - </ActionConfirmingDialog> - <ResetPasswordDialog userId={u.id}> - <ButtonWithTooltip - tooltip={t("admin.users_list.reset_password")} - variant="outline" - disabled={session!.user.id == u.id || !u.localUser} - > - <KeyRound size={16} color="red" /> - </ButtonWithTooltip> - </ResetPasswordDialog> - <UpdateUserDialog - userId={u.id} - currentRole={u.role!} - currentQuota={u.bookmarkQuota} - currentStorageQuota={u.storageQuota} + Delete + </ActionButton> + )} + > + <ButtonWithTooltip + tooltip={t("admin.users_list.delete_user")} + variant="outline" + disabled={session!.user.id == u.id} > - <ButtonWithTooltip - tooltip="Edit User" - variant="outline" - disabled={session!.user.id == u.id} - > - <Pencil size={16} color="red" /> - </ButtonWithTooltip> - </UpdateUserDialog> - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </div> - </AdminCard> - - <AdminCard> - <InvitesList /> - </AdminCard> - </div> + <Trash size={16} color="red" /> + </ButtonWithTooltip> + </ActionConfirmingDialog> + <ResetPasswordDialog userId={u.id}> + <ButtonWithTooltip + tooltip={t("admin.users_list.reset_password")} + variant="outline" + disabled={session!.user.id == u.id || !u.localUser} + > + <KeyRound size={16} color="red" /> + </ButtonWithTooltip> + </ResetPasswordDialog> + <UpdateUserDialog + userId={u.id} + currentRole={u.role!} + currentQuota={u.bookmarkQuota} + currentStorageQuota={u.storageQuota} + > + <ButtonWithTooltip + tooltip="Edit User" + variant="outline" + disabled={session!.user.id == u.id} + > + <Pencil size={16} color="red" /> + </ButtonWithTooltip> + </UpdateUserDialog> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + </AdminCard> ); } diff --git a/apps/web/components/admin/UserListSkeleton.tsx b/apps/web/components/admin/UserListSkeleton.tsx new file mode 100644 index 00000000..3da80aa1 --- /dev/null +++ b/apps/web/components/admin/UserListSkeleton.tsx @@ -0,0 +1,56 @@ +import { AdminCard } from "@/components/admin/AdminCard"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +const headerWidths = ["w-24", "w-32", "w-28", "w-28", "w-20", "w-16", "w-24"]; + +export default function UserListSkeleton() { + return ( + <AdminCard> + <div className="flex flex-col gap-4"> + <div className="mb-2 flex items-center justify-between"> + <Skeleton className="h-6 w-40" /> + <Skeleton className="h-9 w-9" /> + </div> + + <Table> + <TableHeader> + <TableRow> + {headerWidths.map((width, index) => ( + <TableHead key={`user-list-header-${index}`}> + <Skeleton className={`h-4 ${width}`} /> + </TableHead> + ))} + </TableRow> + </TableHeader> + <TableBody> + {Array.from({ length: 4 }).map((_, rowIndex) => ( + <TableRow key={`user-list-row-${rowIndex}`}> + {headerWidths.map((width, cellIndex) => ( + <TableCell key={`user-list-cell-${rowIndex}-${cellIndex}`}> + {cellIndex === headerWidths.length - 1 ? ( + <div className="flex gap-2"> + <Skeleton className="h-6 w-6" /> + <Skeleton className="h-6 w-6" /> + <Skeleton className="h-6 w-6" /> + </div> + ) : ( + <Skeleton className={`h-4 ${width}`} /> + )} + </TableCell> + ))} + </TableRow> + ))} + </TableBody> + </Table> + </div> + </AdminCard> + ); +} diff --git a/apps/web/components/dashboard/BulkBookmarksAction.tsx b/apps/web/components/dashboard/BulkBookmarksAction.tsx index 817521ff..0e74b985 100644 --- a/apps/web/components/dashboard/BulkBookmarksAction.tsx +++ b/apps/web/components/dashboard/BulkBookmarksAction.tsx @@ -7,7 +7,7 @@ import { ActionButtonWithTooltip, } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; -import { useToast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import useBulkActionsStore from "@/lib/bulkActions"; import { useTranslation } from "@/lib/i18n/client"; import { @@ -16,6 +16,7 @@ import { Hash, Link, List, + ListMinus, Pencil, RotateCw, Trash2, @@ -27,6 +28,7 @@ import { useRecrawlBookmark, useUpdateBookmark, } from "@karakeep/shared-react/hooks/bookmarks"; +import { useRemoveBookmarkFromList } from "@karakeep/shared-react/hooks/lists"; import { limitConcurrency } from "@karakeep/shared/concurrency"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; @@ -38,7 +40,11 @@ const MAX_CONCURRENT_BULK_ACTIONS = 50; export default function BulkBookmarksAction() { const { t } = useTranslation(); - const { selectedBookmarks, isBulkEditEnabled } = useBulkActionsStore(); + const { + selectedBookmarks, + isBulkEditEnabled, + listContext: withinListContext, + } = useBulkActionsStore(); const setIsBulkEditEnabled = useBulkActionsStore( (state) => state.setIsBulkEditEnabled, ); @@ -49,8 +55,9 @@ export default function BulkBookmarksAction() { const isEverythingSelected = useBulkActionsStore( (state) => state.isEverythingSelected, ); - const { toast } = useToast(); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isRemoveFromListDialogOpen, setIsRemoveFromListDialogOpen] = + useState(false); const [manageListsModal, setManageListsModalOpen] = useState(false); const [bulkTagModal, setBulkTagModalOpen] = useState(false); const pathname = usePathname(); @@ -93,6 +100,13 @@ export default function BulkBookmarksAction() { onError, }); + const removeBookmarkFromListMutator = useRemoveBookmarkFromList({ + onSuccess: () => { + setIsBulkEditEnabled(false); + }, + onError, + }); + interface UpdateBookmarkProps { favourited?: boolean; archived?: boolean; @@ -185,6 +199,31 @@ export default function BulkBookmarksAction() { setIsDeleteDialogOpen(false); }; + const removeBookmarksFromList = async () => { + if (!withinListContext) return; + + const results = await Promise.allSettled( + limitConcurrency( + selectedBookmarks.map( + (item) => () => + removeBookmarkFromListMutator.mutateAsync({ + bookmarkId: item.id, + listId: withinListContext.id, + }), + ), + MAX_CONCURRENT_BULK_ACTIONS, + ), + ); + + const successes = results.filter((r) => r.status === "fulfilled").length; + if (successes > 0) { + toast({ + description: `${successes} bookmarks have been removed from the list!`, + }); + } + setIsRemoveFromListDialogOpen(false); + }; + const alreadyFavourited = selectedBookmarks.length && selectedBookmarks.every((item) => item.favourited === true); @@ -204,6 +243,18 @@ export default function BulkBookmarksAction() { hidden: !isBulkEditEnabled, }, { + name: t("actions.remove_from_list"), + icon: <ListMinus size={18} />, + action: () => setIsRemoveFromListDialogOpen(true), + isPending: removeBookmarkFromListMutator.isPending, + hidden: + !isBulkEditEnabled || + !withinListContext || + withinListContext.type !== "manual" || + (withinListContext.userRole !== "editor" && + withinListContext.userRole !== "owner"), + }, + { name: t("actions.add_to_list"), icon: <List size={18} />, action: () => setManageListsModalOpen(true), @@ -232,7 +283,7 @@ export default function BulkBookmarksAction() { hidden: !isBulkEditEnabled, }, { - name: t("actions.download_full_page_archive"), + name: t("actions.preserve_offline_archive"), icon: <FileDown size={18} />, action: () => recrawlBookmarks(true), isPending: recrawlBookmarkMutator.isPending, @@ -299,6 +350,27 @@ export default function BulkBookmarksAction() { </ActionButton> )} /> + <ActionConfirmingDialog + open={isRemoveFromListDialogOpen} + setOpen={setIsRemoveFromListDialogOpen} + title={"Remove Bookmarks from List"} + description={ + <p> + Are you sure you want to remove {selectedBookmarks.length} bookmarks + from this list? + </p> + } + actionButton={() => ( + <ActionButton + type="button" + variant="destructive" + loading={removeBookmarkFromListMutator.isPending} + onClick={() => removeBookmarksFromList()} + > + {t("actions.remove")} + </ActionButton> + )} + /> <BulkManageListsModal bookmarkIds={selectedBookmarks.map((b) => b.id)} open={manageListsModal} diff --git a/apps/web/components/dashboard/ErrorFallback.tsx b/apps/web/components/dashboard/ErrorFallback.tsx new file mode 100644 index 00000000..7e4ce0d6 --- /dev/null +++ b/apps/web/components/dashboard/ErrorFallback.tsx @@ -0,0 +1,43 @@ +"use client"; + +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { AlertTriangle, Home, RefreshCw } from "lucide-react"; + +export default function ErrorFallback() { + return ( + <div className="flex flex-1 items-center justify-center rounded-lg bg-slate-50 p-8 shadow-sm dark:bg-slate-700/50 dark:shadow-md"> + <div className="w-full max-w-md space-y-8 text-center"> + <div className="flex justify-center"> + <div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted"> + <AlertTriangle className="h-10 w-10 text-muted-foreground" /> + </div> + </div> + + <div className="space-y-4"> + <h1 className="text-balance text-2xl font-semibold text-foreground"> + Oops! Something went wrong + </h1> + <p className="text-pretty leading-relaxed text-muted-foreground"> + We're sorry, but an unexpected error occurred. Please try again + or contact support if the issue persists. + </p> + </div> + + <div className="space-y-3"> + <Button className="w-full" onClick={() => window.location.reload()}> + <RefreshCw className="mr-2 h-4 w-4" /> + Try Again + </Button> + + <Link href="/" className="block"> + <Button variant="outline" className="w-full"> + <Home className="mr-2 h-4 w-4" /> + Go Home + </Button> + </Link> + </div> + </div> + </div> + ); +} diff --git a/apps/web/components/dashboard/UploadDropzone.tsx b/apps/web/components/dashboard/UploadDropzone.tsx index 8d119467..c76da523 100644 --- a/apps/web/components/dashboard/UploadDropzone.tsx +++ b/apps/web/components/dashboard/UploadDropzone.tsx @@ -1,6 +1,8 @@ "use client"; import React, { useCallback, useState } from "react"; +import { toast } from "@/components/ui/sonner"; +import { BOOKMARK_DRAG_MIME } from "@/lib/bookmark-drag"; import useUpload from "@/lib/hooks/upload-file"; import { cn } from "@/lib/utils"; import { TRPCClientError } from "@trpc/client"; @@ -10,7 +12,6 @@ import { useCreateBookmarkWithPostHook } from "@karakeep/shared-react/hooks/book import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; import LoadingSpinner from "../ui/spinner"; -import { toast } from "../ui/use-toast"; import BookmarkAlreadyExistsToast from "../utils/BookmarkAlreadyExistsToast"; export function useUploadAsset() { @@ -136,7 +137,12 @@ export default function UploadDropzone({ <DropZone noClick onDrop={onDrop} - onDragEnter={() => setDragging(true)} + onDragEnter={(e) => { + // Don't show overlay for internal bookmark card drags + if (!e.dataTransfer.types.includes(BOOKMARK_DRAG_MIME)) { + setDragging(true); + } + }} onDragLeave={() => setDragging(false)} > {({ getRootProps, getInputProps }) => ( diff --git a/apps/web/components/dashboard/bookmarks/BookmarkCard.tsx b/apps/web/components/dashboard/bookmarks/BookmarkCard.tsx index 595a9e00..b120e0b1 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkCard.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkCard.tsx @@ -1,5 +1,6 @@ -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; import { getBookmarkRefreshInterval } from "@karakeep/shared/utils/bookmarkUtils"; @@ -15,20 +16,23 @@ export default function BookmarkCard({ bookmark: ZBookmark; className?: string; }) { - const { data: bookmark } = api.bookmarks.getBookmark.useQuery( - { - bookmarkId: initialData.id, - }, - { - initialData, - refetchInterval: (query) => { - const data = query.state.data; - if (!data) { - return false; - } - return getBookmarkRefreshInterval(data); + const api = useTRPC(); + const { data: bookmark } = useQuery( + api.bookmarks.getBookmark.queryOptions( + { + bookmarkId: initialData.id, }, - }, + { + initialData, + refetchInterval: (query) => { + const data = query.state.data; + if (!data) { + return false; + } + return getBookmarkRefreshInterval(data); + }, + }, + ), ); switch (bookmark.content.type) { diff --git a/apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx b/apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx index a3e5d3b3..7c254336 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx @@ -1,8 +1,8 @@ -import dayjs from "dayjs"; +import { format, isAfter, subYears } from "date-fns"; export default function BookmarkFormattedCreatedAt(prop: { createdAt: Date }) { - const createdAt = dayjs(prop.createdAt); - const oneYearAgo = dayjs().subtract(1, "year"); - const formatString = createdAt.isAfter(oneYearAgo) ? "MMM D" : "MMM D, YYYY"; - return createdAt.format(formatString); + const createdAt = prop.createdAt; + const oneYearAgo = subYears(new Date(), 1); + const formatString = isAfter(createdAt, oneYearAgo) ? "MMM d" : "MMM d, yyyy"; + return format(createdAt, formatString); } diff --git a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx index e8520b1a..f164b275 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx @@ -2,9 +2,11 @@ import type { BookmarksLayoutTypes } from "@/lib/userLocalSettings/types"; import type { ReactNode } from "react"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import Image from "next/image"; import Link from "next/link"; +import { useSession } from "@/lib/auth/client"; +import { BOOKMARK_DRAG_MIME } from "@/lib/bookmark-drag"; import useBulkActionsStore from "@/lib/bulkActions"; import { bookmarkLayoutSwitch, @@ -12,17 +14,28 @@ import { useBookmarkLayout, } from "@/lib/userLocalSettings/bookmarksLayout"; import { cn } from "@/lib/utils"; -import { Check, Image as ImageIcon, NotebookPen } from "lucide-react"; -import { useSession } from "next-auth/react"; +import { useQuery } from "@tanstack/react-query"; +import { + Check, + GripVertical, + Image as ImageIcon, + NotebookPen, +} from "lucide-react"; import { useTheme } from "next-themes"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; +import { useBookmarkListContext } from "@karakeep/shared-react/hooks/bookmark-list-context"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; -import { isBookmarkStillTagging } from "@karakeep/shared/utils/bookmarkUtils"; +import { + getBookmarkTitle, + isBookmarkStillTagging, +} from "@karakeep/shared/utils/bookmarkUtils"; import { switchCase } from "@karakeep/shared/utils/switch"; import BookmarkActionBar from "./BookmarkActionBar"; import BookmarkFormattedCreatedAt from "./BookmarkFormattedCreatedAt"; +import BookmarkOwnerIcon from "./BookmarkOwnerIcon"; import { NotePreview } from "./NotePreview"; import TagList from "./TagList"; @@ -60,6 +73,43 @@ function BottomRow({ ); } +function OwnerIndicator({ bookmark }: { bookmark: ZBookmark }) { + const api = useTRPC(); + const listContext = useBookmarkListContext(); + const collaborators = useQuery( + api.lists.getCollaborators.queryOptions( + { + listId: listContext?.id ?? "", + }, + { + refetchOnWindowFocus: false, + enabled: !!listContext?.hasCollaborators, + }, + ), + ); + + if (!listContext || listContext.userRole === "owner" || !collaborators.data) { + return null; + } + + let owner = undefined; + if (bookmark.userId === collaborators.data.owner?.id) { + owner = collaborators.data.owner; + } else { + owner = collaborators.data.collaborators.find( + (c) => c.userId === bookmark.userId, + )?.user; + } + + if (!owner) return null; + + return ( + <div className="absolute right-2 top-2 z-40 opacity-0 transition-opacity duration-200 group-hover:opacity-100"> + <BookmarkOwnerIcon ownerName={owner.name} ownerAvatar={owner.image} /> + </div> + ); +} + function MultiBookmarkSelector({ bookmark }: { bookmark: ZBookmark }) { const { selectedBookmarks, isBulkEditEnabled } = useBulkActionsStore(); const toggleBookmark = useBulkActionsStore((state) => state.toggleBookmark); @@ -114,6 +164,65 @@ function MultiBookmarkSelector({ bookmark }: { bookmark: ZBookmark }) { ); } +function DragHandle({ + bookmark, + className, +}: { + bookmark: ZBookmark; + className?: string; +}) { + const { isBulkEditEnabled } = useBulkActionsStore(); + const handleDragStart = useCallback( + (e: React.DragEvent) => { + e.stopPropagation(); + e.dataTransfer.setData(BOOKMARK_DRAG_MIME, bookmark.id); + e.dataTransfer.effectAllowed = "copy"; + + // Create a small pill element as the drag preview + const pill = document.createElement("div"); + const title = getBookmarkTitle(bookmark) ?? "Untitled"; + pill.textContent = + title.length > 40 ? title.substring(0, 40) + "\u2026" : title; + Object.assign(pill.style, { + position: "fixed", + left: "-9999px", + top: "-9999px", + padding: "6px 12px", + borderRadius: "8px", + backgroundColor: "hsl(var(--card))", + border: "1px solid hsl(var(--border))", + boxShadow: "0 4px 12px rgba(0,0,0,0.15)", + fontSize: "13px", + fontFamily: "inherit", + color: "hsl(var(--foreground))", + maxWidth: "240px", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }); + document.body.appendChild(pill); + e.dataTransfer.setDragImage(pill, 0, 0); + requestAnimationFrame(() => pill.remove()); + }, + [bookmark], + ); + + if (isBulkEditEnabled) return null; + + return ( + <div + draggable + onDragStart={handleDragStart} + className={cn( + "absolute z-40 cursor-grab rounded bg-background/70 p-0.5 opacity-0 shadow-sm transition-opacity duration-200 group-hover:opacity-100", + className, + )} + > + <GripVertical className="size-4 text-muted-foreground" /> + </div> + ); +} + function ListView({ bookmark, image, @@ -133,11 +242,16 @@ function ListView({ return ( <div className={cn( - "relative flex max-h-96 gap-4 overflow-hidden rounded-lg p-2", + "group relative flex max-h-96 gap-4 overflow-hidden rounded-lg p-2", className, )} > <MultiBookmarkSelector bookmark={bookmark} /> + <OwnerIndicator bookmark={bookmark} /> + <DragHandle + bookmark={bookmark} + className="left-1 top-1/2 -translate-y-1/2" + /> <div className="flex size-32 items-center justify-center overflow-hidden"> {image("list", cn("size-32 rounded-lg", imgFitClass))} </div> @@ -191,12 +305,14 @@ function GridView({ return ( <div className={cn( - "relative flex flex-col overflow-hidden rounded-lg", + "group relative flex flex-col overflow-hidden rounded-lg", className, fitHeight && layout != "grid" ? "max-h-96" : "h-96", )} > <MultiBookmarkSelector bookmark={bookmark} /> + <OwnerIndicator bookmark={bookmark} /> + <DragHandle bookmark={bookmark} className="left-2 top-2" /> {img && <div className="h-56 w-full shrink-0 overflow-hidden">{img}</div>} <div className="flex h-full flex-col justify-between gap-2 overflow-hidden p-2"> <div className="grow-1 flex flex-col gap-2 overflow-hidden"> @@ -228,12 +344,17 @@ function CompactView({ bookmark, title, footer, className }: Props) { return ( <div className={cn( - "relative flex flex-col overflow-hidden rounded-lg", + "group relative flex flex-col overflow-hidden rounded-lg", className, "max-h-96", )} > <MultiBookmarkSelector bookmark={bookmark} /> + <OwnerIndicator bookmark={bookmark} /> + <DragHandle + bookmark={bookmark} + className="left-0.5 top-1/2 -translate-y-1/2" + /> <div className="flex h-full justify-between gap-2 overflow-hidden p-2"> <div className="flex items-center gap-2"> {bookmark.content.type === BookmarkTypes.LINK && diff --git a/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx b/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx index e7fea2c3..a1eab830 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx @@ -1,6 +1,6 @@ import MarkdownEditor from "@/components/ui/markdown/markdown-editor";
import { MarkdownReadonly } from "@/components/ui/markdown/markdown-readonly";
-import { toast } from "@/components/ui/use-toast";
+import { toast } from "@/components/ui/sonner";
import { useUpdateBookmark } from "@karakeep/shared-react/hooks/bookmarks";
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx index 66de6156..c161853d 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx @@ -1,18 +1,26 @@ "use client"; -import { useEffect, useState } from "react"; +import { ChangeEvent, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { useToast } from "@/components/ui/use-toast"; +import { useSession } from "@/lib/auth/client"; import { useClientConfig } from "@/lib/clientConfig"; +import useUpload from "@/lib/hooks/upload-file"; import { useTranslation } from "@/lib/i18n/client"; import { + Archive, + Download, FileDown, + FileText, + ImagePlus, Link, List, ListX, @@ -22,20 +30,25 @@ import { SquarePen, Trash2, } from "lucide-react"; -import { useSession } from "next-auth/react"; +import { toast } from "sonner"; import type { ZBookmark, ZBookmarkedLink, } from "@karakeep/shared/types/bookmarks"; import { - useRecrawlBookmark, - useUpdateBookmark, -} from "@karakeep/shared-react/hooks//bookmarks"; -import { useRemoveBookmarkFromList } from "@karakeep/shared-react/hooks//lists"; + useAttachBookmarkAsset, + useReplaceBookmarkAsset, +} from "@karakeep/shared-react/hooks/assets"; import { useBookmarkGridContext } from "@karakeep/shared-react/hooks/bookmark-grid-context"; import { useBookmarkListContext } from "@karakeep/shared-react/hooks/bookmark-list-context"; +import { + useRecrawlBookmark, + useUpdateBookmark, +} from "@karakeep/shared-react/hooks/bookmarks"; +import { useRemoveBookmarkFromList } from "@karakeep/shared-react/hooks/lists"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; +import { getAssetUrl } from "@karakeep/shared/utils/assetUtils"; import { BookmarkedTextEditor } from "./BookmarkedTextEditor"; import DeleteBookmarkConfirmationDialog from "./DeleteBookmarkConfirmationDialog"; @@ -43,9 +56,35 @@ import { EditBookmarkDialog } from "./EditBookmarkDialog"; import { ArchivedActionIcon, FavouritedActionIcon } from "./icons"; import { useManageListsModal } from "./ManageListsModal"; +interface ActionItem { + id: string; + title: string; + icon: React.ReactNode; + visible: boolean; + disabled: boolean; + className?: string; + onClick: () => void; +} + +interface SubsectionItem { + id: string; + title: string; + icon: React.ReactNode; + visible: boolean; + items: ActionItem[]; +} + +const getBannerSonnerId = (bookmarkId: string) => + `replace-banner-${bookmarkId}`; + +type ActionItemType = ActionItem | SubsectionItem; + +function isSubsectionItem(item: ActionItemType): item is SubsectionItem { + return "items" in item; +} + export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const { t } = useTranslation(); - const { toast } = useToast(); const linkId = bookmark.id; const { data: session } = useSession(); @@ -73,54 +112,122 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const [isTextEditorOpen, setTextEditorOpen] = useState(false); const [isEditBookmarkDialogOpen, setEditBookmarkDialogOpen] = useState(false); + const bannerFileInputRef = useRef<HTMLInputElement>(null); + + const { mutate: uploadBannerAsset } = useUpload({ + onError: (e) => { + toast.error(e.error, { id: getBannerSonnerId(bookmark.id) }); + }, + }); + + const { mutate: attachAsset, isPending: isAttaching } = + useAttachBookmarkAsset({ + onSuccess: () => { + toast.success(t("toasts.bookmarks.update_banner"), { + id: getBannerSonnerId(bookmark.id), + }); + }, + onError: (e) => { + toast.error(e.message, { id: getBannerSonnerId(bookmark.id) }); + }, + }); + + const { mutate: replaceAsset, isPending: isReplacing } = + useReplaceBookmarkAsset({ + onSuccess: () => { + toast.success(t("toasts.bookmarks.update_banner"), { + id: getBannerSonnerId(bookmark.id), + }); + }, + onError: (e) => { + toast.error(e.message, { id: getBannerSonnerId(bookmark.id) }); + }, + }); + const { listId } = useBookmarkGridContext() ?? {}; const withinListContext = useBookmarkListContext(); const onError = () => { - toast({ - variant: "destructive", - title: t("common.something_went_wrong"), - }); + toast.error(t("common.something_went_wrong")); }; const updateBookmarkMutator = useUpdateBookmark({ onSuccess: () => { - toast({ - description: t("toasts.bookmarks.updated"), - }); + toast.success(t("toasts.bookmarks.updated")); }, onError, }); const crawlBookmarkMutator = useRecrawlBookmark({ onSuccess: () => { - toast({ - description: t("toasts.bookmarks.refetch"), - }); + toast.success(t("toasts.bookmarks.refetch")); }, onError, }); const fullPageArchiveBookmarkMutator = useRecrawlBookmark({ onSuccess: () => { - toast({ - description: t("toasts.bookmarks.full_page_archive"), - }); + toast.success(t("toasts.bookmarks.full_page_archive")); + }, + onError, + }); + + const preservePdfMutator = useRecrawlBookmark({ + onSuccess: () => { + toast.success(t("toasts.bookmarks.preserve_pdf")); }, onError, }); const removeFromListMutator = useRemoveBookmarkFromList({ onSuccess: () => { - toast({ - description: t("toasts.bookmarks.delete_from_list"), - }); + toast.success(t("toasts.bookmarks.delete_from_list")); }, onError, }); + const handleBannerFileChange = (event: ChangeEvent<HTMLInputElement>) => { + const files = event.target.files; + if (files && files.length > 0) { + const file = files[0]; + const existingBanner = bookmark.assets.find( + (asset) => asset.assetType === "bannerImage", + ); + + if (existingBanner) { + toast.loading(t("toasts.bookmarks.uploading_banner"), { + id: getBannerSonnerId(bookmark.id), + }); + uploadBannerAsset(file, { + onSuccess: (resp) => { + replaceAsset({ + bookmarkId: bookmark.id, + oldAssetId: existingBanner.id, + newAssetId: resp.assetId, + }); + }, + }); + } else { + toast.loading(t("toasts.bookmarks.uploading_banner"), { + id: getBannerSonnerId(bookmark.id), + }); + uploadBannerAsset(file, { + onSuccess: (resp) => { + attachAsset({ + bookmarkId: bookmark.id, + asset: { + id: resp.assetId, + assetType: "bannerImage", + }, + }); + }, + }); + } + } + }; + // Define action items array - const actionItems = [ + const actionItems: ActionItemType[] = [ { id: "edit", title: t("actions.edit"), @@ -174,19 +281,6 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { }), }, { - id: "download-full-page", - title: t("actions.download_full_page_archive"), - icon: <FileDown className="mr-2 size-4" />, - visible: isOwner && bookmark.content.type === BookmarkTypes.LINK, - disabled: false, - onClick: () => { - fullPageArchiveBookmarkMutator.mutate({ - bookmarkId: bookmark.id, - archiveFullPage: true, - }); - }, - }, - { id: "copy-link", title: t("actions.copy_link"), icon: <Link className="mr-2 size-4" />, @@ -196,9 +290,7 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { navigator.clipboard.writeText( (bookmark.content as ZBookmarkedLink).url, ); - toast({ - description: t("toasts.bookmarks.clipboard_copied"), - }); + toast.success(t("toasts.bookmarks.clipboard_copied")); }, }, { @@ -213,14 +305,15 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { id: "remove-from-list", title: t("actions.remove_from_list"), icon: <ListX className="mr-2 size-4" />, - visible: + visible: Boolean( (isOwner || (withinListContext && (withinListContext.userRole === "editor" || withinListContext.userRole === "owner"))) && - !!listId && - !!withinListContext && - withinListContext.type === "manual", + !!listId && + !!withinListContext && + withinListContext.type === "manual", + ), disabled: demoMode, onClick: () => removeFromListMutator.mutate({ @@ -229,12 +322,98 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { }), }, { - id: "refresh", - title: t("actions.refresh"), - icon: <RotateCw className="mr-2 size-4" />, + id: "offline-copies", + title: t("actions.offline_copies"), + icon: <Archive className="mr-2 size-4" />, visible: isOwner && bookmark.content.type === BookmarkTypes.LINK, - disabled: demoMode, - onClick: () => crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }), + items: [ + { + id: "download-full-page", + title: t("actions.preserve_offline_archive"), + icon: <FileDown className="mr-2 size-4" />, + visible: true, + disabled: demoMode, + onClick: () => { + fullPageArchiveBookmarkMutator.mutate({ + bookmarkId: bookmark.id, + archiveFullPage: true, + }); + }, + }, + { + id: "preserve-pdf", + title: t("actions.preserve_as_pdf"), + icon: <FileText className="mr-2 size-4" />, + visible: true, + disabled: demoMode, + onClick: () => { + preservePdfMutator.mutate({ + bookmarkId: bookmark.id, + storePdf: true, + }); + }, + }, + { + id: "download-full-page-archive", + title: t("actions.download_full_page_archive_file"), + icon: <Download className="mr-2 size-4" />, + visible: + bookmark.content.type === BookmarkTypes.LINK && + !!( + bookmark.content.fullPageArchiveAssetId || + bookmark.content.precrawledArchiveAssetId + ), + disabled: false, + onClick: () => { + const link = bookmark.content as ZBookmarkedLink; + const archiveAssetId = + link.fullPageArchiveAssetId ?? link.precrawledArchiveAssetId; + if (archiveAssetId) { + window.open(getAssetUrl(archiveAssetId), "_blank"); + } + }, + }, + { + id: "download-pdf", + title: t("actions.download_pdf_file"), + icon: <Download className="mr-2 size-4" />, + visible: !!(bookmark.content as ZBookmarkedLink).pdfAssetId, + disabled: false, + onClick: () => { + const link = bookmark.content as ZBookmarkedLink; + if (link.pdfAssetId) { + window.open(getAssetUrl(link.pdfAssetId), "_blank"); + } + }, + }, + ], + }, + { + id: "more", + title: t("actions.more"), + icon: <MoreHorizontal className="mr-2 size-4" />, + visible: isOwner, + items: [ + { + id: "refresh", + title: t("actions.refresh"), + icon: <RotateCw className="mr-2 size-4" />, + visible: bookmark.content.type === BookmarkTypes.LINK, + disabled: demoMode, + onClick: () => + crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }), + }, + { + id: "replace-banner", + title: bookmark.assets.find((a) => a.assetType === "bannerImage") + ? t("actions.replace_banner") + : t("actions.add_banner"), + icon: <ImagePlus className="mr-2 size-4" />, + visible: true, + disabled: demoMode || isAttaching || isReplacing, + onClick: () => bannerFileInputRef.current?.click(), + }, + ], }, { id: "delete", @@ -248,7 +427,12 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { ]; // Filter visible items - const visibleItems = actionItems.filter((item) => item.visible); + const visibleItems: ActionItemType[] = actionItems.filter((item) => { + if (isSubsectionItem(item)) { + return item.visible && item.items.some((subItem) => subItem.visible); + } + return item.visible; + }); // If no items are visible, don't render the dropdown if (visibleItems.length === 0) { @@ -283,19 +467,56 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { </Button> </DropdownMenuTrigger> <DropdownMenuContent className="w-fit"> - {visibleItems.map((item) => ( - <DropdownMenuItem - key={item.id} - disabled={item.disabled} - className={item.className} - onClick={item.onClick} - > - {item.icon} - <span>{item.title}</span> - </DropdownMenuItem> - ))} + {visibleItems.map((item) => { + if (isSubsectionItem(item)) { + const visibleSubItems = item.items.filter( + (subItem) => subItem.visible, + ); + if (visibleSubItems.length === 0) { + return null; + } + return ( + <DropdownMenuSub key={item.id}> + <DropdownMenuSubTrigger> + {item.icon} + <span>{item.title}</span> + </DropdownMenuSubTrigger> + <DropdownMenuSubContent> + {visibleSubItems.map((subItem) => ( + <DropdownMenuItem + key={subItem.id} + disabled={subItem.disabled} + onClick={subItem.onClick} + > + {subItem.icon} + <span>{subItem.title}</span> + </DropdownMenuItem> + ))} + </DropdownMenuSubContent> + </DropdownMenuSub> + ); + } + return ( + <DropdownMenuItem + key={item.id} + disabled={item.disabled} + className={item.className} + onClick={item.onClick} + > + {item.icon} + <span>{item.title}</span> + </DropdownMenuItem> + ); + })} </DropdownMenuContent> </DropdownMenu> + <input + type="file" + ref={bannerFileInputRef} + onChange={handleBannerFileChange} + className="hidden" + accept=".jpg,.jpeg,.png,.webp" + /> </> ); } diff --git a/apps/web/components/dashboard/bookmarks/BookmarkOwnerIcon.tsx b/apps/web/components/dashboard/bookmarks/BookmarkOwnerIcon.tsx new file mode 100644 index 00000000..57770547 --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/BookmarkOwnerIcon.tsx @@ -0,0 +1,31 @@ +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { UserAvatar } from "@/components/ui/user-avatar"; + +interface BookmarkOwnerIconProps { + ownerName: string; + ownerAvatar: string | null; +} + +export default function BookmarkOwnerIcon({ + ownerName, + ownerAvatar, +}: BookmarkOwnerIconProps) { + return ( + <Tooltip> + <TooltipTrigger> + <UserAvatar + name={ownerName} + image={ownerAvatar} + className="size-5 shrink-0 rounded-full ring-1 ring-border" + /> + </TooltipTrigger> + <TooltipContent className="font-sm"> + <p className="font-medium">{ownerName}</p> + </TooltipContent> + </Tooltip> + ); +} diff --git a/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx b/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx index 22b5408e..09843bce 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx @@ -1,4 +1,4 @@ -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; import { useUpdateBookmarkTags } from "@karakeep/shared-react/hooks/bookmarks"; diff --git a/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx index f726c703..b3a1881a 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx @@ -16,6 +16,7 @@ import Masonry from "react-masonry-css"; import resolveConfig from "tailwindcss/resolveConfig"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; +import { useBookmarkListContext } from "@karakeep/shared-react/hooks/bookmark-list-context"; import BookmarkCard from "./BookmarkCard"; import EditorCard from "./EditorCard"; @@ -64,6 +65,7 @@ export default function BookmarksGrid({ const gridColumns = useGridColumns(); const bulkActionsStore = useBulkActionsStore(); const inBookmarkGrid = useInBookmarkGridStore(); + const withinListContext = useBookmarkListContext(); const breakpointConfig = useMemo( () => getBreakpointConfig(gridColumns), [gridColumns], @@ -72,10 +74,13 @@ export default function BookmarksGrid({ useEffect(() => { bulkActionsStore.setVisibleBookmarks(bookmarks); + bulkActionsStore.setListContext(withinListContext); + return () => { bulkActionsStore.setVisibleBookmarks([]); + bulkActionsStore.setListContext(undefined); }; - }, [bookmarks]); + }, [bookmarks, withinListContext?.id]); useEffect(() => { inBookmarkGrid.setInBookmarkGrid(true); @@ -112,12 +117,20 @@ export default function BookmarksGrid({ <> {bookmarkLayoutSwitch(layout, { masonry: ( - <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> + <Masonry + className="-ml-4 flex w-auto" + columnClassName="pl-4" + breakpointCols={breakpointConfig} + > {children} </Masonry> ), grid: ( - <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> + <Masonry + className="-ml-4 flex w-auto" + columnClassName="pl-4" + breakpointCols={breakpointConfig} + > {children} </Masonry> ), diff --git a/apps/web/components/dashboard/bookmarks/BookmarksGridSkeleton.tsx b/apps/web/components/dashboard/bookmarks/BookmarksGridSkeleton.tsx index b592919b..9adc7b7a 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarksGridSkeleton.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarksGridSkeleton.tsx @@ -69,12 +69,20 @@ export default function BookmarksGridSkeleton({ return bookmarkLayoutSwitch(layout, { masonry: ( - <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> + <Masonry + className="-ml-4 flex w-auto" + columnClassName="pl-4" + breakpointCols={breakpointConfig} + > {children} </Masonry> ), grid: ( - <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> + <Masonry + className="-ml-4 flex w-auto" + columnClassName="pl-4" + breakpointCols={breakpointConfig} + > {children} </Masonry> ), diff --git a/apps/web/components/dashboard/bookmarks/BulkManageListsModal.tsx b/apps/web/components/dashboard/bookmarks/BulkManageListsModal.tsx index 23afa7d2..1d4f5814 100644 --- a/apps/web/components/dashboard/bookmarks/BulkManageListsModal.tsx +++ b/apps/web/components/dashboard/bookmarks/BulkManageListsModal.tsx @@ -15,7 +15,7 @@ import { FormItem, FormMessage, } from "@/components/ui/form"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; diff --git a/apps/web/components/dashboard/bookmarks/BulkTagModal.tsx b/apps/web/components/dashboard/bookmarks/BulkTagModal.tsx index 431f0fcd..c790a5fe 100644 --- a/apps/web/components/dashboard/bookmarks/BulkTagModal.tsx +++ b/apps/web/components/dashboard/bookmarks/BulkTagModal.tsx @@ -7,10 +7,11 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; +import { useQueries } from "@tanstack/react-query"; import { useUpdateBookmarkTags } from "@karakeep/shared-react/hooks/bookmarks"; -import { api } from "@karakeep/shared-react/trpc"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { limitConcurrency } from "@karakeep/shared/concurrency"; import { ZBookmark } from "@karakeep/shared/types/bookmarks"; @@ -25,9 +26,12 @@ export default function BulkTagModal({ open: boolean; setOpen: (open: boolean) => void; }) { - const results = api.useQueries((t) => - bookmarkIds.map((id) => t.bookmarks.getBookmark({ bookmarkId: id })), - ); + const api = useTRPC(); + const results = useQueries({ + queries: bookmarkIds.map((id) => + api.bookmarks.getBookmark.queryOptions({ bookmarkId: id }), + ), + }); const bookmarks = results .map((r) => r.data) diff --git a/apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx b/apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx index 7e680706..8e7a4d34 100644 --- a/apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx +++ b/apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx @@ -1,7 +1,7 @@ import { usePathname, useRouter } from "next/navigation"; import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; import { useDeleteBookmark } from "@karakeep/shared-react/hooks//bookmarks"; diff --git a/apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx b/apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx index 76208158..8b77365c 100644 --- a/apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx +++ b/apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx @@ -25,18 +25,19 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { toast } from "@/components/ui/sonner"; import { Textarea } from "@/components/ui/textarea"; -import { toast } from "@/components/ui/use-toast"; import { useDialogFormReset } from "@/lib/hooks/useDialogFormReset"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; import { format } from "date-fns"; import { CalendarIcon } from "lucide-react"; import { useForm } from "react-hook-form"; import { useUpdateBookmark } from "@karakeep/shared-react/hooks/bookmarks"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes, ZBookmark, @@ -60,10 +61,11 @@ export function EditBookmarkDialog({ open: boolean; setOpen: (v: boolean) => void; }) { + const api = useTRPC(); const { t } = useTranslation(); - const { data: assetContent, isLoading: isAssetContentLoading } = - api.bookmarks.getBookmark.useQuery( + const { data: assetContent, isLoading: isAssetContentLoading } = useQuery( + api.bookmarks.getBookmark.queryOptions( { bookmarkId: bookmark.id, includeContent: true, @@ -73,11 +75,13 @@ export function EditBookmarkDialog({ select: (b) => b.content.type == BookmarkTypes.ASSET ? b.content.content : null, }, - ); + ), + ); const bookmarkToDefault = (bookmark: ZBookmark) => ({ bookmarkId: bookmark.id, summary: bookmark.summary, + note: bookmark.note === null ? undefined : bookmark.note, title: getBookmarkTitle(bookmark), createdAt: bookmark.createdAt ?? new Date(), // Link specific defaults (only if bookmark is a link) @@ -196,6 +200,26 @@ export function EditBookmarkDialog({ /> )} + { + <FormField + control={form.control} + name="note" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("common.note")}</FormLabel> + <FormControl> + <Textarea + placeholder="Bookmark notes" + {...field} + value={field.value ?? ""} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + } + {isLink && ( <FormField control={form.control} diff --git a/apps/web/components/dashboard/bookmarks/EditorCard.tsx b/apps/web/components/dashboard/bookmarks/EditorCard.tsx index fa752c5f..4636bcb9 100644 --- a/apps/web/components/dashboard/bookmarks/EditorCard.tsx +++ b/apps/web/components/dashboard/bookmarks/EditorCard.tsx @@ -5,8 +5,8 @@ import { Form, FormControl, FormItem } from "@/components/ui/form"; import { Kbd } from "@/components/ui/kbd"; import MultipleChoiceDialog from "@/components/ui/multiple-choice-dialog"; import { Separator } from "@/components/ui/separator"; +import { toast } from "@/components/ui/sonner"; import { Textarea } from "@/components/ui/textarea"; -import { toast } from "@/components/ui/use-toast"; import BookmarkAlreadyExistsToast from "@/components/utils/BookmarkAlreadyExistsToast"; import { useClientConfig } from "@/lib/clientConfig"; import { useTranslation } from "@/lib/i18n/client"; diff --git a/apps/web/components/dashboard/bookmarks/ManageListsModal.tsx b/apps/web/components/dashboard/bookmarks/ManageListsModal.tsx index 7c3827ab..1fee0505 100644 --- a/apps/web/components/dashboard/bookmarks/ManageListsModal.tsx +++ b/apps/web/components/dashboard/bookmarks/ManageListsModal.tsx @@ -16,11 +16,11 @@ import { FormItem, FormMessage, } from "@/components/ui/form"; +import { toast } from "@/components/ui/sonner"; import LoadingSpinner from "@/components/ui/spinner"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; import { Archive, X } from "lucide-react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -30,6 +30,7 @@ import { useBookmarkLists, useRemoveBookmarkFromList, } from "@karakeep/shared-react/hooks/lists"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkListSelector } from "../lists/BookmarkListSelector"; import ArchiveBookmarkButton from "./action-buttons/ArchiveBookmarkButton"; @@ -43,6 +44,7 @@ export default function ManageListsModal({ open: boolean; setOpen: (open: boolean) => void; }) { + const api = useTRPC(); const { t } = useTranslation(); const formSchema = z.object({ listId: z.string({ @@ -61,13 +63,14 @@ export default function ManageListsModal({ { enabled: open }, ); - const { data: alreadyInList, isPending: isAlreadyInListPending } = - api.lists.getListsOfBookmark.useQuery( + const { data: alreadyInList, isPending: isAlreadyInListPending } = useQuery( + api.lists.getListsOfBookmark.queryOptions( { bookmarkId, }, { enabled: open }, - ); + ), + ); const isLoading = isAllListsPending || isAlreadyInListPending; diff --git a/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx b/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx index b2cf118e..5f107663 100644 --- a/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx +++ b/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx @@ -1,8 +1,8 @@ import React from "react"; import { ActionButton } from "@/components/ui/action-button"; import { MarkdownReadonly } from "@/components/ui/markdown/markdown-readonly"; +import { toast } from "@/components/ui/sonner"; import LoadingSpinner from "@/components/ui/spinner"; -import { toast } from "@/components/ui/use-toast"; import { useClientConfig } from "@/lib/clientConfig"; import { useTranslation } from "@/lib/i18n/client"; import { cn } from "@/lib/utils"; diff --git a/apps/web/components/dashboard/bookmarks/TagList.tsx b/apps/web/components/dashboard/bookmarks/TagList.tsx index f1c319ea..88611c52 100644 --- a/apps/web/components/dashboard/bookmarks/TagList.tsx +++ b/apps/web/components/dashboard/bookmarks/TagList.tsx @@ -1,8 +1,8 @@ import Link from "next/link"; import { badgeVariants } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; +import { useSession } from "@/lib/auth/client"; import { cn } from "@/lib/utils"; -import { useSession } from "next-auth/react"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; diff --git a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx index bc06c647..ec4a9d8a 100644 --- a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx +++ b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx @@ -13,25 +13,32 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { useClientConfig } from "@/lib/clientConfig"; -import { api } from "@/lib/trpc"; +import { useTranslation } from "@/lib/i18n/client"; import { cn } from "@/lib/utils"; -import { keepPreviousData } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { Command as CommandPrimitive } from "cmdk"; import { Check, Loader2, Plus, Sparkles, X } from "lucide-react"; import type { ZBookmarkTags } from "@karakeep/shared/types/tags"; +import { useTRPC } from "@karakeep/shared-react/trpc"; export function TagsEditor({ tags: _tags, onAttach, onDetach, disabled, + allowCreation = true, + placeholder, }: { tags: ZBookmarkTags[]; onAttach: (tag: { tagName: string; tagId?: string }) => void; onDetach: (tag: { tagName: string; tagId: string }) => void; disabled?: boolean; + allowCreation?: boolean; + placeholder?: string; }) { + const api = useTRPC(); + const { t } = useTranslation(); const demoMode = !!useClientConfig().demoMode; const isDisabled = demoMode || disabled; const inputRef = React.useRef<HTMLInputElement>(null); @@ -40,6 +47,7 @@ export function TagsEditor({ const [inputValue, setInputValue] = React.useState(""); const [optimisticTags, setOptimisticTags] = useState<ZBookmarkTags[]>(_tags); const tempIdCounter = React.useRef(0); + const hasInitializedRef = React.useRef(_tags.length > 0); const generateTempId = React.useCallback(() => { tempIdCounter.current += 1; @@ -54,25 +62,42 @@ export function TagsEditor({ }, []); React.useEffect(() => { + // When allowCreation is false, only sync on initial load + // After that, rely on optimistic updates to avoid re-ordering + if (!allowCreation) { + if (!hasInitializedRef.current && _tags.length > 0) { + hasInitializedRef.current = true; + setOptimisticTags(_tags); + } + return; + } + + // For allowCreation mode, sync server state with optimistic state setOptimisticTags((prev) => { - let results = prev; + // Start with a copy to avoid mutating the previous state + const results = [...prev]; + let changed = false; + for (const tag of _tags) { const idx = results.findIndex((t) => t.name === tag.name); if (idx == -1) { results.push(tag); + changed = true; continue; } if (results[idx].id.startsWith("temp-")) { results[idx] = tag; + changed = true; continue; } } - return results; + + return changed ? results : prev; }); - }, [_tags]); + }, [_tags, allowCreation]); - const { data: filteredOptions, isLoading: isExistingTagsLoading } = - api.tags.list.useQuery( + const { data: filteredOptions, isLoading: isExistingTagsLoading } = useQuery( + api.tags.list.queryOptions( { nameContains: inputValue, limit: 50, @@ -91,7 +116,8 @@ export function TagsEditor({ placeholderData: keepPreviousData, gcTime: inputValue.length > 0 ? 60_000 : 3_600_000, }, - ); + ), + ); const selectedValues = optimisticTags.map((tag) => tag.id); @@ -122,7 +148,7 @@ export function TagsEditor({ (opt) => opt.name.toLowerCase() === trimmedInputValue.toLowerCase(), ); - if (!exactMatch) { + if (!exactMatch && allowCreation) { return [ { id: "create-new", @@ -136,7 +162,7 @@ export function TagsEditor({ } return baseOptions; - }, [filteredOptions, trimmedInputValue]); + }, [filteredOptions, trimmedInputValue, allowCreation]); const onChange = ( actionMeta: @@ -256,6 +282,24 @@ export function TagsEditor({ } }; + const inputPlaceholder = + placeholder ?? + (allowCreation + ? t("tags.search_or_create_placeholder", { + defaultValue: "Search or create tags...", + }) + : t("tags.search_placeholder", { + defaultValue: "Search tags...", + })); + const visiblePlaceholder = + optimisticTags.length === 0 ? inputPlaceholder : undefined; + const inputWidth = Math.max( + inputValue.length > 0 + ? inputValue.length + : Math.min(visiblePlaceholder?.length ?? 1, 24), + 1, + ); + return ( <div ref={containerRef} className="w-full"> <Popover open={open && !isDisabled} onOpenChange={handleOpenChange}> @@ -311,8 +355,9 @@ export function TagsEditor({ value={inputValue} onKeyDown={handleKeyDown} onValueChange={(v) => setInputValue(v)} + placeholder={visiblePlaceholder} className="bg-transparent outline-none placeholder:text-muted-foreground" - style={{ width: `${Math.max(inputValue.length, 1)}ch` }} + style={{ width: `${inputWidth}ch` }} disabled={isDisabled} /> {isExistingTagsLoading && ( @@ -329,7 +374,7 @@ export function TagsEditor({ <CommandList className="max-h-64"> {displayedOptions.length === 0 ? ( <CommandEmpty> - {trimmedInputValue ? ( + {trimmedInputValue && allowCreation ? ( <div className="flex items-center justify-between px-2 py-1.5"> <span>Create "{trimmedInputValue}"</span> <Button diff --git a/apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx b/apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx index 968d0326..e9bee653 100644 --- a/apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx +++ b/apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx @@ -3,13 +3,14 @@ import { useEffect } from "react"; import UploadDropzone from "@/components/dashboard/UploadDropzone"; import { useSortOrderStore } from "@/lib/store/useSortOrderStore"; -import { api } from "@/lib/trpc"; +import { useInfiniteQuery } from "@tanstack/react-query"; import type { ZGetBookmarksRequest, ZGetBookmarksResponse, } from "@karakeep/shared/types/bookmarks"; import { BookmarkGridContextProvider } from "@karakeep/shared-react/hooks/bookmark-grid-context"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import BookmarksGrid from "./BookmarksGrid"; @@ -23,6 +24,7 @@ export default function UpdatableBookmarksGrid({ showEditorCard?: boolean; itemsPerPage?: number; }) { + const api = useTRPC(); let sortOrder = useSortOrderStore((state) => state.sortOrder); if (sortOrder === "relevance") { // Relevance is not supported in the `getBookmarks` endpoint. @@ -32,17 +34,19 @@ export default function UpdatableBookmarksGrid({ const finalQuery = { ...query, sortOrder, includeContent: false }; const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } = - api.bookmarks.getBookmarks.useInfiniteQuery( - { ...finalQuery, useCursorV2: true }, - { - initialData: () => ({ - pages: [initialBookmarks], - pageParams: [query.cursor], - }), - initialCursor: null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - refetchOnMount: true, - }, + useInfiniteQuery( + api.bookmarks.getBookmarks.infiniteQueryOptions( + { ...finalQuery, useCursorV2: true }, + { + initialData: () => ({ + pages: [initialBookmarks], + pageParams: [query.cursor ?? null], + }), + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + refetchOnMount: true, + }, + ), ); useEffect(() => { diff --git a/apps/web/components/dashboard/bookmarks/action-buttons/ArchiveBookmarkButton.tsx b/apps/web/components/dashboard/bookmarks/action-buttons/ArchiveBookmarkButton.tsx index d45cfc82..48d3c7ac 100644 --- a/apps/web/components/dashboard/bookmarks/action-buttons/ArchiveBookmarkButton.tsx +++ b/apps/web/components/dashboard/bookmarks/action-buttons/ArchiveBookmarkButton.tsx @@ -1,9 +1,10 @@ import React from "react"; import { ActionButton, ActionButtonProps } from "@/components/ui/action-button"; -import { toast } from "@/components/ui/use-toast"; -import { api } from "@/lib/trpc"; +import { toast } from "@/components/ui/sonner"; +import { useQuery } from "@tanstack/react-query"; import { useUpdateBookmark } from "@karakeep/shared-react/hooks/bookmarks"; +import { useTRPC } from "@karakeep/shared-react/trpc"; interface ArchiveBookmarkButtonProps extends Omit<ActionButtonProps, "loading" | "disabled"> { @@ -15,13 +16,16 @@ const ArchiveBookmarkButton = React.forwardRef< HTMLButtonElement, ArchiveBookmarkButtonProps >(({ bookmarkId, onDone, ...props }, ref) => { - const { data } = api.bookmarks.getBookmark.useQuery( - { bookmarkId }, - { - select: (data) => ({ - archived: data.archived, - }), - }, + const api = useTRPC(); + const { data } = useQuery( + api.bookmarks.getBookmark.queryOptions( + { bookmarkId }, + { + select: (data) => ({ + archived: data.archived, + }), + }, + ), ); const { mutate: updateBookmark, isPending: isArchivingBookmark } = diff --git a/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx b/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx index 52a9ab0c..b1870644 100644 --- a/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx +++ b/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx @@ -11,6 +11,7 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; +import { toast } from "@/components/ui/sonner"; import LoadingSpinner from "@/components/ui/spinner"; import { Table, @@ -20,14 +21,14 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; import { distance } from "fastest-levenshtein"; import { Check, Combine, X } from "lucide-react"; import { useMergeTag } from "@karakeep/shared-react/hooks/tags"; +import { useTRPC } from "@karakeep/shared-react/trpc"; interface Suggestion { mergeIntoId: string; @@ -199,12 +200,15 @@ function SuggestionRow({ } export function TagDuplicationDetection() { + const api = useTRPC(); const [expanded, setExpanded] = useState(false); - let { data: allTags } = api.tags.list.useQuery( - {}, - { - refetchOnWindowFocus: false, - }, + let { data: allTags } = useQuery( + api.tags.list.queryOptions( + {}, + { + refetchOnWindowFocus: false, + }, + ), ); const { suggestions, updateMergeInto, setSuggestions, deleteSuggestion } = diff --git a/apps/web/components/dashboard/feeds/FeedSelector.tsx b/apps/web/components/dashboard/feeds/FeedSelector.tsx index db95a042..58fae503 100644 --- a/apps/web/components/dashboard/feeds/FeedSelector.tsx +++ b/apps/web/components/dashboard/feeds/FeedSelector.tsx @@ -7,8 +7,10 @@ import { SelectValue, } from "@/components/ui/select"; import LoadingSpinner from "@/components/ui/spinner"; -import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; export function FeedSelector({ value, @@ -21,9 +23,12 @@ export function FeedSelector({ onChange: (value: string) => void; placeholder?: string; }) { - const { data, isPending } = api.feeds.list.useQuery(undefined, { - select: (data) => data.feeds, - }); + const api = useTRPC(); + const { data, isPending } = useQuery( + api.feeds.list.queryOptions(undefined, { + select: (data) => data.feeds, + }), + ); if (isPending) { return <LoadingSpinner />; diff --git a/apps/web/components/dashboard/header/ProfileOptions.tsx b/apps/web/components/dashboard/header/ProfileOptions.tsx index 7ccc0078..8a2b0165 100644 --- a/apps/web/components/dashboard/header/ProfileOptions.tsx +++ b/apps/web/components/dashboard/header/ProfileOptions.tsx @@ -1,5 +1,6 @@ "use client"; +import { useMemo } from "react"; import Link from "next/link"; import { redirect, useRouter } from "next/navigation"; import { useToggleTheme } from "@/components/theme-provider"; @@ -11,11 +12,24 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Separator } from "@/components/ui/separator"; +import { UserAvatar } from "@/components/ui/user-avatar"; +import { useSession } from "@/lib/auth/client"; import { useTranslation } from "@/lib/i18n/client"; -import { LogOut, Moon, Paintbrush, Settings, Shield, Sun } from "lucide-react"; -import { useSession } from "next-auth/react"; +import { + BookOpen, + LogOut, + Moon, + Paintbrush, + Puzzle, + Settings, + Shield, + Sun, + Twitter, +} from "lucide-react"; import { useTheme } from "next-themes"; +import { useWhoAmI } from "@karakeep/shared-react/hooks/users"; + import { AdminNoticeBadge } from "../../admin/AdminNotices"; function DarkModeToggle() { @@ -43,7 +57,12 @@ export default function SidebarProfileOptions() { const { t } = useTranslation(); const toggleTheme = useToggleTheme(); const { data: session } = useSession(); + const { data: whoami } = useWhoAmI(); const router = useRouter(); + + const avatarImage = whoami?.image ?? null; + const avatarUrl = useMemo(() => avatarImage ?? null, [avatarImage]); + if (!session) return redirect("/"); return ( @@ -53,13 +72,21 @@ export default function SidebarProfileOptions() { className="border-new-gray-200 aspect-square rounded-full border-4 bg-black p-0 text-white" variant="ghost" > - {session.user.name?.charAt(0) ?? "U"} + <UserAvatar + image={avatarUrl} + name={session.user.name} + className="h-full w-full rounded-full" + /> </Button> </DropdownMenuTrigger> <DropdownMenuContent className="mr-2 min-w-64 p-2"> <div className="flex gap-2"> - <div className="border-new-gray-200 flex aspect-square size-11 items-center justify-center rounded-full border-4 bg-black p-0 text-white"> - {session.user.name?.charAt(0) ?? "U"} + <div className="border-new-gray-200 flex aspect-square size-11 items-center justify-center overflow-hidden rounded-full border-4 bg-black p-0 text-white"> + <UserAvatar + image={avatarUrl} + name={session.user.name} + className="h-full w-full" + /> </div> <div className="flex flex-col"> <p>{session.user.name}</p> @@ -95,6 +122,25 @@ export default function SidebarProfileOptions() { <DarkModeToggle /> </DropdownMenuItem> <Separator className="my-2" /> + <DropdownMenuItem asChild> + <a href="https://karakeep.app/apps" target="_blank" rel="noreferrer"> + <Puzzle className="mr-2 size-4" /> + {t("options.apps_extensions")} + </a> + </DropdownMenuItem> + <DropdownMenuItem asChild> + <a href="https://docs.karakeep.app" target="_blank" rel="noreferrer"> + <BookOpen className="mr-2 size-4" /> + {t("options.documentation")} + </a> + </DropdownMenuItem> + <DropdownMenuItem asChild> + <a href="https://x.com/karakeep_app" target="_blank" rel="noreferrer"> + <Twitter className="mr-2 size-4" /> + {t("options.follow_us_on_x")} + </a> + </DropdownMenuItem> + <Separator className="my-2" /> <DropdownMenuItem onClick={() => router.push("/logout")}> <LogOut className="mr-2 size-4" /> <span>{t("actions.sign_out")}</span> diff --git a/apps/web/components/dashboard/highlights/AllHighlights.tsx b/apps/web/components/dashboard/highlights/AllHighlights.tsx index 928f4e05..c7e809ec 100644 --- a/apps/web/components/dashboard/highlights/AllHighlights.tsx +++ b/apps/web/components/dashboard/highlights/AllHighlights.tsx @@ -5,15 +5,14 @@ import Link from "next/link"; import { ActionButton } from "@/components/ui/action-button"; import { Input } from "@/components/ui/input"; import useRelativeTime from "@/lib/hooks/relative-time"; -import { api } from "@/lib/trpc"; import { Separator } from "@radix-ui/react-dropdown-menu"; -import dayjs from "dayjs"; -import relativeTime from "dayjs/plugin/relativeTime"; +import { useInfiniteQuery } from "@tanstack/react-query"; import { Dot, LinkIcon, Search, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useInView } from "react-intersection-observer"; import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { ZGetAllHighlightsResponse, ZHighlight, @@ -21,8 +20,6 @@ import { import HighlightCard from "./HighlightCard"; -dayjs.extend(relativeTime); - function Highlight({ highlight }: { highlight: ZHighlight }) { const { fromNow, localCreatedAt } = useRelativeTime(highlight.createdAt); const { t } = useTranslation(); @@ -49,6 +46,7 @@ export default function AllHighlights({ }: { highlights: ZGetAllHighlightsResponse; }) { + const api = useTRPC(); const { t } = useTranslation(); const [searchInput, setSearchInput] = useState(""); const debouncedSearch = useDebounce(searchInput, 300); @@ -56,28 +54,32 @@ export default function AllHighlights({ // Use search endpoint if searchQuery is provided, otherwise use getAll const useSearchQuery = debouncedSearch.trim().length > 0; - const getAllQuery = api.highlights.getAll.useInfiniteQuery( - {}, - { - enabled: !useSearchQuery, - initialData: !useSearchQuery - ? () => ({ - pages: [initialHighlights], - pageParams: [null], - }) - : undefined, - initialCursor: null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - }, + const getAllQuery = useInfiniteQuery( + api.highlights.getAll.infiniteQueryOptions( + {}, + { + enabled: !useSearchQuery, + initialData: !useSearchQuery + ? () => ({ + pages: [initialHighlights], + pageParams: [null], + }) + : undefined, + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ), ); - const searchQueryResult = api.highlights.search.useInfiniteQuery( - { text: debouncedSearch }, - { - enabled: useSearchQuery, - initialCursor: null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - }, + const searchQueryResult = useInfiniteQuery( + api.highlights.search.infiniteQueryOptions( + { text: debouncedSearch }, + { + enabled: useSearchQuery, + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ), ); const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = diff --git a/apps/web/components/dashboard/highlights/HighlightCard.tsx b/apps/web/components/dashboard/highlights/HighlightCard.tsx index 51421e0f..e7e7c519 100644 --- a/apps/web/components/dashboard/highlights/HighlightCard.tsx +++ b/apps/web/components/dashboard/highlights/HighlightCard.tsx @@ -1,5 +1,5 @@ import { ActionButton } from "@/components/ui/action-button"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { cn } from "@/lib/utils"; import { Trash2 } from "lucide-react"; diff --git a/apps/web/components/dashboard/lists/AllListsView.tsx b/apps/web/components/dashboard/lists/AllListsView.tsx index 7a7c9504..52d65756 100644 --- a/apps/web/components/dashboard/lists/AllListsView.tsx +++ b/apps/web/components/dashboard/lists/AllListsView.tsx @@ -2,7 +2,6 @@ import { useMemo, useState } from "react"; import Link from "next/link"; -import { EditListModal } from "@/components/dashboard/lists/EditListModal"; import { Button } from "@/components/ui/button"; import { Collapsible, @@ -10,7 +9,7 @@ import { CollapsibleTriggerChevron, } from "@/components/ui/collapsible"; import { useTranslation } from "@/lib/i18n/client"; -import { MoreHorizontal, Plus } from "lucide-react"; +import { MoreHorizontal } from "lucide-react"; import type { ZBookmarkList } from "@karakeep/shared/types/lists"; import { @@ -89,12 +88,6 @@ export default function AllListsView({ return ( <ul> - <EditListModal> - <Button className="mb-2 flex h-full w-full items-center"> - <Plus /> - <span>{t("lists.new_list")}</span> - </Button> - </EditListModal> <ListItem collapsible={false} name={t("lists.favourites")} diff --git a/apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx b/apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx index 2bb5f41b..0070b827 100644 --- a/apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx +++ b/apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx @@ -1,10 +1,10 @@ import { useEffect, useState } from "react"; import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; import { FullPageSpinner } from "@/components/ui/full-page-spinner"; -import { api } from "@/lib/trpc"; -import { keepPreviousData } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { useBookmarkLists } from "@karakeep/shared-react/hooks/lists"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { ZBookmarkList } from "@karakeep/shared/types/lists"; import { ZBookmarkListTreeNode } from "@karakeep/shared/utils/listUtils"; @@ -101,6 +101,7 @@ export function CollapsibleBookmarkLists({ filter?: (node: ZBookmarkListTreeNode) => boolean; indentOffset?: number; }) { + const api = useTRPC(); // If listsData is provided, use it directly. Otherwise, fetch it. let { data: fetchedData } = useBookmarkLists(undefined, { initialData: initialData ? { lists: initialData } : undefined, @@ -108,9 +109,11 @@ export function CollapsibleBookmarkLists({ }); const data = listsData || fetchedData; - const { data: listStats } = api.lists.stats.useQuery(undefined, { - placeholderData: keepPreviousData, - }); + const { data: listStats } = useQuery( + api.lists.stats.queryOptions(undefined, { + placeholderData: keepPreviousData, + }), + ); if (!data) { return <FullPageSpinner />; diff --git a/apps/web/components/dashboard/lists/DeleteListConfirmationDialog.tsx b/apps/web/components/dashboard/lists/DeleteListConfirmationDialog.tsx index 4996ddf1..6c091d7a 100644 --- a/apps/web/components/dashboard/lists/DeleteListConfirmationDialog.tsx +++ b/apps/web/components/dashboard/lists/DeleteListConfirmationDialog.tsx @@ -3,8 +3,8 @@ import { usePathname, useRouter } from "next/navigation"; import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; import { Label } from "@/components/ui/label"; +import { toast } from "@/components/ui/sonner"; import { Switch } from "@/components/ui/switch"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; import type { ZBookmarkList } from "@karakeep/shared/types/lists"; diff --git a/apps/web/components/dashboard/lists/EditListModal.tsx b/apps/web/components/dashboard/lists/EditListModal.tsx index 5febf88c..21a61d65 100644 --- a/apps/web/components/dashboard/lists/EditListModal.tsx +++ b/apps/web/components/dashboard/lists/EditListModal.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -34,7 +36,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; import data from "@emoji-mart/data"; import Picker from "@emoji-mart/react"; diff --git a/apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx b/apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx index 62dbbcef..859f4c83 100644 --- a/apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx +++ b/apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx @@ -2,11 +2,12 @@ import React from "react"; import { usePathname, useRouter } from "next/navigation"; import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { ZBookmarkList } from "@karakeep/shared/types/lists"; +import { useTRPC } from "@karakeep/shared-react/trpc"; export default function LeaveListConfirmationDialog({ list, @@ -19,34 +20,37 @@ export default function LeaveListConfirmationDialog({ open: boolean; setOpen: (v: boolean) => void; }) { + const api = useTRPC(); const { t } = useTranslation(); const currentPath = usePathname(); const router = useRouter(); - const utils = api.useUtils(); + const queryClient = useQueryClient(); - const { mutate: leaveList, isPending } = api.lists.leaveList.useMutation({ - onSuccess: () => { - toast({ - description: t("lists.leave_list.success", { - icon: list.icon, - name: list.name, - }), - }); - setOpen(false); - // Invalidate the lists cache - utils.lists.list.invalidate(); - // If currently viewing this list, redirect to lists page - if (currentPath.includes(list.id)) { - router.push("/dashboard/lists"); - } - }, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message || t("common.something_went_wrong"), - }); - }, - }); + const { mutate: leaveList, isPending } = useMutation( + api.lists.leaveList.mutationOptions({ + onSuccess: () => { + toast({ + description: t("lists.leave_list.success", { + icon: list.icon, + name: list.name, + }), + }); + setOpen(false); + // Invalidate the lists cache + queryClient.invalidateQueries(api.lists.list.pathFilter()); + // If currently viewing this list, redirect to lists page + if (currentPath.includes(list.id)) { + router.push("/dashboard/lists"); + } + }, + onError: (error) => { + toast({ + variant: "destructive", + description: error.message || t("common.something_went_wrong"), + }); + }, + }), + ); return ( <ActionConfirmingDialog diff --git a/apps/web/components/dashboard/lists/ListHeader.tsx b/apps/web/components/dashboard/lists/ListHeader.tsx index 8e014e2a..4176a80e 100644 --- a/apps/web/components/dashboard/lists/ListHeader.tsx +++ b/apps/web/components/dashboard/lists/ListHeader.tsx @@ -6,13 +6,14 @@ import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, - TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { UserAvatar } from "@/components/ui/user-avatar"; import { useTranslation } from "@/lib/i18n/client"; -import { MoreHorizontal, SearchIcon, Users } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { MoreHorizontal, SearchIcon } from "lucide-react"; -import { api } from "@karakeep/shared-react/trpc"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { parseSearchQuery } from "@karakeep/shared/searchQueryParser"; import { ZBookmarkList } from "@karakeep/shared/types/lists"; @@ -24,15 +25,30 @@ export default function ListHeader({ }: { initialData: ZBookmarkList; }) { + const api = useTRPC(); const { t } = useTranslation(); const router = useRouter(); - const { data: list, error } = api.lists.get.useQuery( - { - listId: initialData.id, - }, - { - initialData, - }, + const { data: list, error } = useQuery( + api.lists.get.queryOptions( + { + listId: initialData.id, + }, + { + initialData, + }, + ), + ); + + const { data: collaboratorsData } = useQuery( + api.lists.getCollaborators.queryOptions( + { + listId: initialData.id, + }, + { + refetchOnWindowFocus: false, + enabled: list.hasCollaborators, + }, + ), ); const parsedQuery = useMemo(() => { @@ -55,22 +71,44 @@ export default function ListHeader({ <span className="text-2xl"> {list.icon} {list.name} </span> - {list.hasCollaborators && ( - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Users className="size-5 text-primary" /> - </TooltipTrigger> - <TooltipContent> - <p>{t("lists.shared")}</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> + {list.hasCollaborators && collaboratorsData && ( + <div className="group flex"> + {collaboratorsData.owner && ( + <Tooltip> + <TooltipTrigger> + <div className="-mr-2 transition-all duration-300 ease-out group-hover:mr-1"> + <UserAvatar + name={collaboratorsData.owner.name} + image={collaboratorsData.owner.image} + className="size-5 shrink-0 rounded-full ring-2 ring-background" + /> + </div> + </TooltipTrigger> + <TooltipContent> + <p>{collaboratorsData.owner.name}</p> + </TooltipContent> + </Tooltip> + )} + {collaboratorsData.collaborators.map((collab) => ( + <Tooltip key={collab.userId}> + <TooltipTrigger> + <div className="-mr-2 transition-all duration-300 ease-out group-hover:mr-1"> + <UserAvatar + name={collab.user.name} + image={collab.user.image} + className="size-5 shrink-0 rounded-full ring-2 ring-background" + /> + </div> + </TooltipTrigger> + <TooltipContent> + <p>{collab.user.name}</p> + </TooltipContent> + </Tooltip> + ))} + </div> )} {list.description && ( - <span className="text-lg text-gray-400"> - {`(${list.description})`} - </span> + <span className="text-lg text-gray-400">{`(${list.description})`}</span> )} </div> <div className="flex items-center"> diff --git a/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx b/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx index 0a55c5fe..518e6440 100644 --- a/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx +++ b/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx @@ -22,11 +22,13 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; +import { UserAvatar } from "@/components/ui/user-avatar"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, Trash2, UserPlus, Users } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { ZBookmarkList } from "@karakeep/shared/types/lists"; export function ManageCollaboratorsModal({ @@ -42,6 +44,7 @@ export function ManageCollaboratorsModal({ children?: React.ReactNode; readOnly?: boolean; }) { + const api = useTRPC(); if ( (userOpen !== undefined && !userSetOpen) || (userOpen === undefined && userSetOpen) @@ -60,82 +63,102 @@ export function ManageCollaboratorsModal({ >("viewer"); const { t } = useTranslation(); - const utils = api.useUtils(); + const queryClient = useQueryClient(); const invalidateListCaches = () => Promise.all([ - utils.lists.getCollaborators.invalidate({ listId: list.id }), - utils.lists.get.invalidate({ listId: list.id }), - utils.lists.list.invalidate(), - utils.bookmarks.getBookmarks.invalidate({ listId: list.id }), + queryClient.invalidateQueries( + api.lists.getCollaborators.queryFilter({ listId: list.id }), + ), + queryClient.invalidateQueries( + api.lists.get.queryFilter({ listId: list.id }), + ), + queryClient.invalidateQueries(api.lists.list.pathFilter()), + queryClient.invalidateQueries( + api.bookmarks.getBookmarks.queryFilter({ listId: list.id }), + ), ]); // Fetch collaborators - const { data: collaboratorsData, isLoading } = - api.lists.getCollaborators.useQuery({ listId: list.id }, { enabled: open }); + const { data: collaboratorsData, isLoading } = useQuery( + api.lists.getCollaborators.queryOptions( + { listId: list.id }, + { enabled: open }, + ), + ); // Mutations - const addCollaborator = api.lists.addCollaborator.useMutation({ - onSuccess: async () => { - toast({ - description: t("lists.collaborators.invitation_sent"), - }); - setNewCollaboratorEmail(""); - await invalidateListCaches(); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message || t("lists.collaborators.failed_to_add"), - }); - }, - }); + const addCollaborator = useMutation( + api.lists.addCollaborator.mutationOptions({ + onSuccess: async () => { + toast({ + description: t("lists.collaborators.invitation_sent"), + }); + setNewCollaboratorEmail(""); + await invalidateListCaches(); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: error.message || t("lists.collaborators.failed_to_add"), + }); + }, + }), + ); - const removeCollaborator = api.lists.removeCollaborator.useMutation({ - onSuccess: async () => { - toast({ - description: t("lists.collaborators.removed"), - }); - await invalidateListCaches(); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message || t("lists.collaborators.failed_to_remove"), - }); - }, - }); + const removeCollaborator = useMutation( + api.lists.removeCollaborator.mutationOptions({ + onSuccess: async () => { + toast({ + description: t("lists.collaborators.removed"), + }); + await invalidateListCaches(); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: + error.message || t("lists.collaborators.failed_to_remove"), + }); + }, + }), + ); - const updateCollaboratorRole = api.lists.updateCollaboratorRole.useMutation({ - onSuccess: async () => { - toast({ - description: t("lists.collaborators.role_updated"), - }); - await invalidateListCaches(); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: - error.message || t("lists.collaborators.failed_to_update_role"), - }); - }, - }); + const updateCollaboratorRole = useMutation( + api.lists.updateCollaboratorRole.mutationOptions({ + onSuccess: async () => { + toast({ + description: t("lists.collaborators.role_updated"), + }); + await invalidateListCaches(); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: + error.message || t("lists.collaborators.failed_to_update_role"), + }); + }, + }), + ); - const revokeInvitation = api.lists.revokeInvitation.useMutation({ - onSuccess: async () => { - toast({ - description: t("lists.collaborators.invitation_revoked"), - }); - await invalidateListCaches(); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message || t("lists.collaborators.failed_to_revoke"), - }); - }, - }); + const revokeInvitation = useMutation( + api.lists.revokeInvitation.mutationOptions({ + onSuccess: async () => { + toast({ + description: t("lists.collaborators.invitation_revoked"), + }); + await invalidateListCaches(); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: + error.message || t("lists.collaborators.failed_to_revoke"), + }); + }, + }), + ); const handleAddCollaborator = () => { if (!newCollaboratorEmail.trim()) { @@ -256,15 +279,22 @@ export function ManageCollaboratorsModal({ key={`owner-${collaboratorsData.owner.id}`} className="flex items-center justify-between rounded-lg border p-3" > - <div className="flex-1"> - <div className="font-medium"> - {collaboratorsData.owner.name} - </div> - {collaboratorsData.owner.email && ( - <div className="text-sm text-muted-foreground"> - {collaboratorsData.owner.email} + <div className="flex flex-1 items-center gap-3"> + <UserAvatar + name={collaboratorsData.owner.name} + image={collaboratorsData.owner.image} + className="size-10 ring-1 ring-border" + /> + <div className="flex-1"> + <div className="font-medium"> + {collaboratorsData.owner.name} </div> - )} + {collaboratorsData.owner.email && ( + <div className="text-sm text-muted-foreground"> + {collaboratorsData.owner.email} + </div> + )} + </div> </div> <div className="text-sm capitalize text-muted-foreground"> {t("lists.collaborators.owner")} @@ -278,27 +308,34 @@ export function ManageCollaboratorsModal({ key={collaborator.id} className="flex items-center justify-between rounded-lg border p-3" > - <div className="flex-1"> - <div className="flex items-center gap-2"> - <div className="font-medium"> - {collaborator.user.name} + <div className="flex flex-1 items-center gap-3"> + <UserAvatar + name={collaborator.user.name} + image={collaborator.user.image} + className="size-10 ring-1 ring-border" + /> + <div className="flex-1"> + <div className="flex items-center gap-2"> + <div className="font-medium"> + {collaborator.user.name} + </div> + {collaborator.status === "pending" && ( + <Badge variant="outline" className="text-xs"> + {t("lists.collaborators.pending")} + </Badge> + )} + {collaborator.status === "declined" && ( + <Badge variant="destructive" className="text-xs"> + {t("lists.collaborators.declined")} + </Badge> + )} </div> - {collaborator.status === "pending" && ( - <Badge variant="outline" className="text-xs"> - {t("lists.collaborators.pending")} - </Badge> - )} - {collaborator.status === "declined" && ( - <Badge variant="destructive" className="text-xs"> - {t("lists.collaborators.declined")} - </Badge> + {collaborator.user.email && ( + <div className="text-sm text-muted-foreground"> + {collaborator.user.email} + </div> )} </div> - {collaborator.user.email && ( - <div className="text-sm text-muted-foreground"> - {collaborator.user.email} - </div> - )} </div> {readOnly ? ( <div className="text-sm capitalize text-muted-foreground"> diff --git a/apps/web/components/dashboard/lists/MergeListModal.tsx b/apps/web/components/dashboard/lists/MergeListModal.tsx index 0b7d362a..b22cd1a2 100644 --- a/apps/web/components/dashboard/lists/MergeListModal.tsx +++ b/apps/web/components/dashboard/lists/MergeListModal.tsx @@ -19,8 +19,8 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/sonner"; import { Switch } from "@/components/ui/switch"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { X } from "lucide-react"; diff --git a/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx b/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx index c453a91f..7c13dbeb 100644 --- a/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx +++ b/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx @@ -8,11 +8,13 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Loader2, Mail, X } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + interface Invitation { id: string; role: string; @@ -27,41 +29,51 @@ interface Invitation { } function InvitationRow({ invitation }: { invitation: Invitation }) { + const api = useTRPC(); const { t } = useTranslation(); - const utils = api.useUtils(); + const queryClient = useQueryClient(); - const acceptInvitation = api.lists.acceptInvitation.useMutation({ - onSuccess: async () => { - toast({ - description: t("lists.invitations.accepted"), - }); - await Promise.all([ - utils.lists.getPendingInvitations.invalidate(), - utils.lists.list.invalidate(), - ]); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message || t("lists.invitations.failed_to_accept"), - }); - }, - }); + const acceptInvitation = useMutation( + api.lists.acceptInvitation.mutationOptions({ + onSuccess: async () => { + toast({ + description: t("lists.invitations.accepted"), + }); + await Promise.all([ + queryClient.invalidateQueries( + api.lists.getPendingInvitations.pathFilter(), + ), + queryClient.invalidateQueries(api.lists.list.pathFilter()), + ]); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: error.message || t("lists.invitations.failed_to_accept"), + }); + }, + }), + ); - const declineInvitation = api.lists.declineInvitation.useMutation({ - onSuccess: async () => { - toast({ - description: t("lists.invitations.declined"), - }); - await utils.lists.getPendingInvitations.invalidate(); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message || t("lists.invitations.failed_to_decline"), - }); - }, - }); + const declineInvitation = useMutation( + api.lists.declineInvitation.mutationOptions({ + onSuccess: async () => { + toast({ + description: t("lists.invitations.declined"), + }); + await queryClient.invalidateQueries( + api.lists.getPendingInvitations.pathFilter(), + ); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: + error.message || t("lists.invitations.failed_to_decline"), + }); + }, + }), + ); return ( <div className="flex items-center justify-between rounded-lg border p-4"> @@ -126,10 +138,12 @@ function InvitationRow({ invitation }: { invitation: Invitation }) { } export function PendingInvitationsCard() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: invitations, isLoading } = - api.lists.getPendingInvitations.useQuery(); + const { data: invitations, isLoading } = useQuery( + api.lists.getPendingInvitations.queryOptions(), + ); if (isLoading) { return null; @@ -142,9 +156,13 @@ export function PendingInvitationsCard() { return ( <Card> <CardHeader> - <CardTitle className="flex items-center gap-2"> + <CardTitle className="flex items-center gap-2 font-normal"> <Mail className="h-5 w-5" /> - {t("lists.invitations.pending")} ({invitations.length}) + {t("lists.invitations.pending")} + + <span className="rounded bg-secondary p-1 text-sm text-secondary-foreground"> + {invitations.length} + </span> </CardTitle> <CardDescription>{t("lists.invitations.description")}</CardDescription> </CardHeader> diff --git a/apps/web/components/dashboard/lists/RssLink.tsx b/apps/web/components/dashboard/lists/RssLink.tsx index 1be48681..2ac53c93 100644 --- a/apps/web/components/dashboard/lists/RssLink.tsx +++ b/apps/web/components/dashboard/lists/RssLink.tsx @@ -7,29 +7,39 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { useClientConfig } from "@/lib/clientConfig"; -import { api } from "@/lib/trpc"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, RotateCcw } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + export default function RssLink({ listId }: { listId: string }) { + const api = useTRPC(); const { t } = useTranslation(); const clientConfig = useClientConfig(); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); - const { mutate: regenRssToken, isPending: isRegenPending } = - api.lists.regenRssToken.useMutation({ + const { mutate: regenRssToken, isPending: isRegenPending } = useMutation( + api.lists.regenRssToken.mutationOptions({ onSuccess: () => { - apiUtils.lists.getRssToken.invalidate({ listId }); + queryClient.invalidateQueries( + api.lists.getRssToken.queryFilter({ listId }), + ); }, - }); - const { mutate: clearRssToken, isPending: isClearPending } = - api.lists.clearRssToken.useMutation({ + }), + ); + const { mutate: clearRssToken, isPending: isClearPending } = useMutation( + api.lists.clearRssToken.mutationOptions({ onSuccess: () => { - apiUtils.lists.getRssToken.invalidate({ listId }); + queryClient.invalidateQueries( + api.lists.getRssToken.queryFilter({ listId }), + ); }, - }); - const { data: rssToken, isLoading: isTokenLoading } = - api.lists.getRssToken.useQuery({ listId }); + }), + ); + const { data: rssToken, isLoading: isTokenLoading } = useQuery( + api.lists.getRssToken.queryOptions({ listId }), + ); const rssUrl = useMemo(() => { if (!rssToken || !rssToken.token) { diff --git a/apps/web/components/dashboard/preview/ActionBar.tsx b/apps/web/components/dashboard/preview/ActionBar.tsx index 6e4cd5a2..9603465e 100644 --- a/apps/web/components/dashboard/preview/ActionBar.tsx +++ b/apps/web/components/dashboard/preview/ActionBar.tsx @@ -1,12 +1,12 @@ import { useState } from "react"; import { ActionButton } from "@/components/ui/action-button"; import { Button } from "@/components/ui/button"; +import { toast } from "@/components/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; import { Pencil, Trash2 } from "lucide-react"; diff --git a/apps/web/components/dashboard/preview/AttachmentBox.tsx b/apps/web/components/dashboard/preview/AttachmentBox.tsx index 73eea640..654f3211 100644 --- a/apps/web/components/dashboard/preview/AttachmentBox.tsx +++ b/apps/web/components/dashboard/preview/AttachmentBox.tsx @@ -8,7 +8,7 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import FilePickerButton from "@/components/ui/file-picker-button"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { ASSET_TYPE_TO_ICON } from "@/lib/attachments"; import useUpload from "@/lib/hooks/upload-file"; import { useTranslation } from "@/lib/i18n/client"; diff --git a/apps/web/components/dashboard/preview/BookmarkPreview.tsx b/apps/web/components/dashboard/preview/BookmarkPreview.tsx index 7e6bf814..719cdff8 100644 --- a/apps/web/components/dashboard/preview/BookmarkPreview.tsx +++ b/apps/web/components/dashboard/preview/BookmarkPreview.tsx @@ -13,12 +13,13 @@ import { TooltipPortal, TooltipTrigger, } from "@/components/ui/tooltip"; +import { useSession } from "@/lib/auth/client"; import useRelativeTime from "@/lib/hooks/relative-time"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; import { Building, CalendarDays, ExternalLink, User } from "lucide-react"; -import { useSession } from "next-auth/react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; import { getBookmarkRefreshInterval, @@ -116,24 +117,27 @@ export default function BookmarkPreview({ bookmarkId: string; initialData?: ZBookmark; }) { + const api = useTRPC(); const { t } = useTranslation(); const [activeTab, setActiveTab] = useState<string>("content"); const { data: session } = useSession(); - const { data: bookmark } = api.bookmarks.getBookmark.useQuery( - { - bookmarkId, - }, - { - initialData, - refetchInterval: (query) => { - const data = query.state.data; - if (!data) { - return false; - } - return getBookmarkRefreshInterval(data); + const { data: bookmark } = useQuery( + api.bookmarks.getBookmark.queryOptions( + { + bookmarkId, }, - }, + { + initialData, + refetchInterval: (query) => { + const data = query.state.data; + if (!data) { + return false; + } + return getBookmarkRefreshInterval(data); + }, + }, + ), ); if (!bookmark) { diff --git a/apps/web/components/dashboard/preview/HighlightsBox.tsx b/apps/web/components/dashboard/preview/HighlightsBox.tsx index 41ab7d74..e8503fd9 100644 --- a/apps/web/components/dashboard/preview/HighlightsBox.tsx +++ b/apps/web/components/dashboard/preview/HighlightsBox.tsx @@ -5,10 +5,12 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { Separator } from "@radix-ui/react-dropdown-menu"; +import { useQuery } from "@tanstack/react-query"; import { ChevronsDownUp } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + import HighlightCard from "../highlights/HighlightCard"; export default function HighlightsBox({ @@ -18,10 +20,12 @@ export default function HighlightsBox({ bookmarkId: string; readOnly: boolean; }) { + const api = useTRPC(); const { t } = useTranslation(); - const { data: highlights, isPending: isLoading } = - api.highlights.getForBookmark.useQuery({ bookmarkId }); + const { data: highlights, isPending: isLoading } = useQuery( + api.highlights.getForBookmark.queryOptions({ bookmarkId }), + ); if (isLoading || !highlights || highlights?.highlights.length === 0) { return null; diff --git a/apps/web/components/dashboard/preview/LinkContentSection.tsx b/apps/web/components/dashboard/preview/LinkContentSection.tsx index 64b62df6..f4e344ac 100644 --- a/apps/web/components/dashboard/preview/LinkContentSection.tsx +++ b/apps/web/components/dashboard/preview/LinkContentSection.tsx @@ -16,16 +16,19 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { useTranslation } from "@/lib/i18n/client"; +import { useSession } from "@/lib/auth/client"; +import { Trans, useTranslation } from "@/lib/i18n/client"; +import { useReaderSettings } from "@/lib/readerSettings"; import { AlertTriangle, Archive, BookOpen, Camera, ExpandIcon, + FileText, + Info, Video, } from "lucide-react"; -import { useSession } from "next-auth/react"; import { useQueryState } from "nuqs"; import { ErrorBoundary } from "react-error-boundary"; @@ -34,8 +37,10 @@ import { ZBookmark, ZBookmarkedLink, } from "@karakeep/shared/types/bookmarks"; +import { READER_FONT_FAMILIES } from "@karakeep/shared/types/readers"; import { contentRendererRegistry } from "./content-renderers"; +import ReaderSettingsPopover from "./ReaderSettingsPopover"; import ReaderView from "./ReaderView"; function CustomRendererErrorFallback({ error }: { error: Error }) { @@ -100,12 +105,23 @@ function VideoSection({ link }: { link: ZBookmarkedLink }) { ); } +function PDFSection({ link }: { link: ZBookmarkedLink }) { + return ( + <iframe + title="PDF Viewer" + src={`/api/assets/${link.pdfAssetId}`} + className="relative h-full min-w-full" + /> + ); +} + export default function LinkContentSection({ bookmark, }: { bookmark: ZBookmark; }) { const { t } = useTranslation(); + const { settings } = useReaderSettings(); const availableRenderers = contentRendererRegistry.getRenderers(bookmark); const defaultSection = availableRenderers.length > 0 ? availableRenderers[0].id : "cached"; @@ -135,6 +151,11 @@ export default function LinkContentSection({ <ScrollArea className="h-full"> <ReaderView className="prose mx-auto dark:prose-invert" + style={{ + fontFamily: READER_FONT_FAMILIES[settings.fontFamily], + fontSize: `${settings.fontSize}px`, + lineHeight: settings.lineHeight, + }} bookmarkId={bookmark.id} readOnly={!isOwner} /> @@ -144,6 +165,8 @@ export default function LinkContentSection({ content = <FullPageArchiveSection link={bookmark.content} />; } else if (section === "video") { content = <VideoSection link={bookmark.content} />; + } else if (section === "pdf") { + content = <PDFSection link={bookmark.content} />; } else { content = <ScreenshotSection link={bookmark.content} />; } @@ -188,6 +211,12 @@ export default function LinkContentSection({ {t("common.screenshot")} </div> </SelectItem> + <SelectItem value="pdf" disabled={!bookmark.content.pdfAssetId}> + <div className="flex items-center"> + <FileText className="mr-2 h-4 w-4" /> + {t("common.pdf")} + </div> + </SelectItem> <SelectItem value="archive" disabled={ @@ -213,16 +242,47 @@ export default function LinkContentSection({ </SelectContent> </Select> {section === "cached" && ( + <> + <ReaderSettingsPopover /> + <Tooltip> + <TooltipTrigger> + <Link + href={`/reader/${bookmark.id}`} + className={buttonVariants({ variant: "outline" })} + > + <ExpandIcon className="h-4 w-4" /> + </Link> + </TooltipTrigger> + <TooltipContent side="bottom">FullScreen</TooltipContent> + </Tooltip> + </> + )} + {section === "archive" && ( <Tooltip> - <TooltipTrigger> - <Link - href={`/reader/${bookmark.id}`} - className={buttonVariants({ variant: "outline" })} - > - <ExpandIcon className="h-4 w-4" /> - </Link> + <TooltipTrigger asChild> + <div className="flex h-10 items-center gap-1 rounded-md border border-blue-500/50 bg-blue-50 px-3 text-blue-700 dark:bg-blue-950 dark:text-blue-300"> + <Info className="h-4 w-4" /> + </div> </TooltipTrigger> - <TooltipContent side="bottom">FullScreen</TooltipContent> + <TooltipContent side="bottom" className="max-w-sm"> + <p className="text-sm"> + <Trans + i18nKey="preview.archive_info" + components={{ + 1: ( + <Link + prefetch={false} + href={`/api/assets/${bookmark.content.fullPageArchiveAssetId ?? bookmark.content.precrawledArchiveAssetId}`} + download + className="font-medium underline" + > + link + </Link> + ), + }} + /> + </p> + </TooltipContent> </Tooltip> )} </div> diff --git a/apps/web/components/dashboard/preview/NoteEditor.tsx b/apps/web/components/dashboard/preview/NoteEditor.tsx index 538aff2e..86807569 100644 --- a/apps/web/components/dashboard/preview/NoteEditor.tsx +++ b/apps/web/components/dashboard/preview/NoteEditor.tsx @@ -1,5 +1,5 @@ +import { toast } from "@/components/ui/sonner"; import { Textarea } from "@/components/ui/textarea"; -import { toast } from "@/components/ui/use-toast"; import { useClientConfig } from "@/lib/clientConfig"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; diff --git a/apps/web/components/dashboard/preview/ReaderSettingsPopover.tsx b/apps/web/components/dashboard/preview/ReaderSettingsPopover.tsx new file mode 100644 index 00000000..f37b8263 --- /dev/null +++ b/apps/web/components/dashboard/preview/ReaderSettingsPopover.tsx @@ -0,0 +1,457 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Slider } from "@/components/ui/slider"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useTranslation } from "@/lib/i18n/client"; +import { useReaderSettings } from "@/lib/readerSettings"; +import { + Globe, + Laptop, + Minus, + Plus, + RotateCcw, + Settings, + Type, + X, +} from "lucide-react"; + +import { + formatFontSize, + formatLineHeight, + READER_DEFAULTS, + READER_SETTING_CONSTRAINTS, +} from "@karakeep/shared/types/readers"; + +interface ReaderSettingsPopoverProps { + open?: boolean; + onOpenChange?: (open: boolean) => void; + variant?: "outline" | "ghost"; +} + +export default function ReaderSettingsPopover({ + open, + onOpenChange, + variant = "outline", +}: ReaderSettingsPopoverProps) { + const { t } = useTranslation(); + const { + settings, + serverSettings, + localOverrides, + sessionOverrides, + hasSessionChanges, + hasLocalOverrides, + isSaving, + updateSession, + clearSession, + saveToDevice, + clearLocalOverride, + saveToServer, + } = useReaderSettings(); + + // Helper to get the effective server value (server setting or default) + const getServerValue = <K extends keyof typeof serverSettings>(key: K) => { + return serverSettings[key] ?? READER_DEFAULTS[key]; + }; + + // Helper to check if a setting has a local override + const hasLocalOverride = (key: keyof typeof localOverrides) => { + return localOverrides[key] !== undefined; + }; + + // Build tooltip message for the settings button + const getSettingsTooltip = () => { + if (hasSessionChanges && hasLocalOverrides) { + return t("settings.info.reader_settings.tooltip_preview_and_local"); + } + if (hasSessionChanges) { + return t("settings.info.reader_settings.tooltip_preview"); + } + if (hasLocalOverrides) { + return t("settings.info.reader_settings.tooltip_local"); + } + return t("settings.info.reader_settings.tooltip_default"); + }; + + return ( + <Popover open={open} onOpenChange={onOpenChange}> + <Tooltip> + <TooltipTrigger asChild> + <PopoverTrigger asChild> + <Button variant={variant} size="icon" className="relative"> + <Settings className="h-4 w-4" /> + {(hasSessionChanges || hasLocalOverrides) && ( + <span className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-primary" /> + )} + </Button> + </PopoverTrigger> + </TooltipTrigger> + <TooltipContent side="bottom"> + <p>{getSettingsTooltip()}</p> + </TooltipContent> + </Tooltip> + <PopoverContent + side="bottom" + align="center" + collisionPadding={32} + className="flex w-80 flex-col overflow-hidden p-0" + style={{ + maxHeight: "var(--radix-popover-content-available-height)", + }} + > + <div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4"> + <div className="flex items-center justify-between pb-2"> + <div className="flex items-center gap-2"> + <Type className="h-4 w-4" /> + <h3 className="font-semibold"> + {t("settings.info.reader_settings.title")} + </h3> + </div> + {hasSessionChanges && ( + <span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary"> + {t("settings.info.reader_settings.preview")} + </span> + )} + </div> + + <div className="space-y-4"> + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <label className="text-sm font-medium"> + {t("settings.info.reader_settings.font_family")} + </label> + <div className="flex items-center gap-1"> + {sessionOverrides.fontFamily !== undefined && ( + <span className="text-xs text-muted-foreground"> + {t("settings.info.reader_settings.preview_inline")} + </span> + )} + {hasLocalOverride("fontFamily") && + sessionOverrides.fontFamily === undefined && ( + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="h-5 w-5 text-muted-foreground hover:text-foreground" + onClick={() => clearLocalOverride("fontFamily")} + > + <X className="h-3 w-3" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p> + {t( + "settings.info.reader_settings.clear_override_hint", + { + value: t( + `settings.info.reader_settings.${getServerValue("fontFamily")}` as const, + ), + }, + )} + </p> + </TooltipContent> + </Tooltip> + )} + </div> + </div> + <Select + value={settings.fontFamily} + onValueChange={(value) => + updateSession({ + fontFamily: value as "serif" | "sans" | "mono", + }) + } + > + <SelectTrigger + className={ + hasLocalOverride("fontFamily") && + sessionOverrides.fontFamily === undefined + ? "border-primary/50" + : "" + } + > + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="serif"> + {t("settings.info.reader_settings.serif")} + </SelectItem> + <SelectItem value="sans"> + {t("settings.info.reader_settings.sans")} + </SelectItem> + <SelectItem value="mono"> + {t("settings.info.reader_settings.mono")} + </SelectItem> + </SelectContent> + </Select> + </div> + + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <label className="text-sm font-medium"> + {t("settings.info.reader_settings.font_size")} + </label> + <div className="flex items-center gap-1"> + <span className="text-sm text-muted-foreground"> + {formatFontSize(settings.fontSize)} + {sessionOverrides.fontSize !== undefined && ( + <span className="ml-1 text-xs"> + {t("settings.info.reader_settings.preview_inline")} + </span> + )} + </span> + {hasLocalOverride("fontSize") && + sessionOverrides.fontSize === undefined && ( + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="h-5 w-5 text-muted-foreground hover:text-foreground" + onClick={() => clearLocalOverride("fontSize")} + > + <X className="h-3 w-3" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p> + {t( + "settings.info.reader_settings.clear_override_hint", + { + value: formatFontSize( + getServerValue("fontSize"), + ), + }, + )} + </p> + </TooltipContent> + </Tooltip> + )} + </div> + </div> + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="icon" + className="h-7 w-7 bg-transparent" + onClick={() => + updateSession({ + fontSize: Math.max( + READER_SETTING_CONSTRAINTS.fontSize.min, + settings.fontSize - + READER_SETTING_CONSTRAINTS.fontSize.step, + ), + }) + } + > + <Minus className="h-3 w-3" /> + </Button> + <Slider + value={[settings.fontSize]} + onValueChange={([value]) => + updateSession({ fontSize: value }) + } + max={READER_SETTING_CONSTRAINTS.fontSize.max} + min={READER_SETTING_CONSTRAINTS.fontSize.min} + step={READER_SETTING_CONSTRAINTS.fontSize.step} + className={`flex-1 ${ + hasLocalOverride("fontSize") && + sessionOverrides.fontSize === undefined + ? "[&_[role=slider]]:border-primary/50" + : "" + }`} + /> + <Button + variant="outline" + size="icon" + className="h-7 w-7 bg-transparent" + onClick={() => + updateSession({ + fontSize: Math.min( + READER_SETTING_CONSTRAINTS.fontSize.max, + settings.fontSize + + READER_SETTING_CONSTRAINTS.fontSize.step, + ), + }) + } + > + <Plus className="h-3 w-3" /> + </Button> + </div> + </div> + + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <label className="text-sm font-medium"> + {t("settings.info.reader_settings.line_height")} + </label> + <div className="flex items-center gap-1"> + <span className="text-sm text-muted-foreground"> + {formatLineHeight(settings.lineHeight)} + {sessionOverrides.lineHeight !== undefined && ( + <span className="ml-1 text-xs"> + {t("settings.info.reader_settings.preview_inline")} + </span> + )} + </span> + {hasLocalOverride("lineHeight") && + sessionOverrides.lineHeight === undefined && ( + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="h-5 w-5 text-muted-foreground hover:text-foreground" + onClick={() => clearLocalOverride("lineHeight")} + > + <X className="h-3 w-3" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p> + {t( + "settings.info.reader_settings.clear_override_hint", + { + value: formatLineHeight( + getServerValue("lineHeight"), + ), + }, + )} + </p> + </TooltipContent> + </Tooltip> + )} + </div> + </div> + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="icon" + className="h-7 w-7 bg-transparent" + onClick={() => + updateSession({ + lineHeight: Math.max( + READER_SETTING_CONSTRAINTS.lineHeight.min, + Math.round( + (settings.lineHeight - + READER_SETTING_CONSTRAINTS.lineHeight.step) * + 10, + ) / 10, + ), + }) + } + > + <Minus className="h-3 w-3" /> + </Button> + <Slider + value={[settings.lineHeight]} + onValueChange={([value]) => + updateSession({ lineHeight: value }) + } + max={READER_SETTING_CONSTRAINTS.lineHeight.max} + min={READER_SETTING_CONSTRAINTS.lineHeight.min} + step={READER_SETTING_CONSTRAINTS.lineHeight.step} + className={`flex-1 ${ + hasLocalOverride("lineHeight") && + sessionOverrides.lineHeight === undefined + ? "[&_[role=slider]]:border-primary/50" + : "" + }`} + /> + <Button + variant="outline" + size="icon" + className="h-7 w-7 bg-transparent" + onClick={() => + updateSession({ + lineHeight: Math.min( + READER_SETTING_CONSTRAINTS.lineHeight.max, + Math.round( + (settings.lineHeight + + READER_SETTING_CONSTRAINTS.lineHeight.step) * + 10, + ) / 10, + ), + }) + } + > + <Plus className="h-3 w-3" /> + </Button> + </div> + </div> + + {hasSessionChanges && ( + <> + <Separator /> + + <div className="space-y-2"> + <Button + variant="outline" + size="sm" + className="w-full" + onClick={() => clearSession()} + > + <RotateCcw className="mr-2 h-4 w-4" /> + {t("settings.info.reader_settings.reset_preview")} + </Button> + + <div className="flex gap-2"> + <Button + variant="outline" + size="sm" + className="flex-1" + disabled={isSaving} + onClick={() => saveToDevice()} + > + <Laptop className="mr-2 h-4 w-4" /> + {t("settings.info.reader_settings.save_to_device")} + </Button> + <Button + variant="default" + size="sm" + className="flex-1" + disabled={isSaving} + onClick={() => saveToServer()} + > + <Globe className="mr-2 h-4 w-4" /> + {t("settings.info.reader_settings.save_to_all_devices")} + </Button> + </div> + + <p className="text-center text-xs text-muted-foreground"> + {t("settings.info.reader_settings.save_hint")} + </p> + </div> + </> + )} + + {!hasSessionChanges && ( + <p className="text-center text-xs text-muted-foreground"> + {t("settings.info.reader_settings.adjust_hint")} + </p> + )} + </div> + </div> + </PopoverContent> + </Popover> + ); +} diff --git a/apps/web/components/dashboard/preview/ReaderView.tsx b/apps/web/components/dashboard/preview/ReaderView.tsx index f2f843ee..76070534 100644 --- a/apps/web/components/dashboard/preview/ReaderView.tsx +++ b/apps/web/components/dashboard/preview/ReaderView.tsx @@ -1,12 +1,15 @@ import { FullPageSpinner } from "@/components/ui/full-page-spinner"; -import { toast } from "@/components/ui/use-toast"; -import { api } from "@/lib/trpc"; +import { toast } from "@/components/ui/sonner"; +import { useTranslation } from "@/lib/i18n/client"; +import { useQuery } from "@tanstack/react-query"; +import { FileX } from "lucide-react"; import { useCreateHighlight, useDeleteHighlight, useUpdateHighlight, } from "@karakeep/shared-react/hooks/highlights"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; import BookmarkHTMLHighlighter from "./BookmarkHtmlHighlighter"; @@ -22,11 +25,15 @@ export default function ReaderView({ style?: React.CSSProperties; readOnly: boolean; }) { - const { data: highlights } = api.highlights.getForBookmark.useQuery({ - bookmarkId, - }); - const { data: cachedContent, isPending: isCachedContentLoading } = - api.bookmarks.getBookmark.useQuery( + const { t } = useTranslation(); + const api = useTRPC(); + const { data: highlights } = useQuery( + api.highlights.getForBookmark.queryOptions({ + bookmarkId, + }), + ); + const { data: cachedContent, isPending: isCachedContentLoading } = useQuery( + api.bookmarks.getBookmark.queryOptions( { bookmarkId, includeContent: true, @@ -37,7 +44,8 @@ export default function ReaderView({ ? data.content.htmlContent : null, }, - ); + ), + ); const { mutate: createHighlight } = useCreateHighlight({ onSuccess: () => { @@ -86,7 +94,23 @@ export default function ReaderView({ content = <FullPageSpinner />; } else if (!cachedContent) { content = ( - <div className="text-destructive">Failed to fetch link content ...</div> + <div className="flex h-full w-full items-center justify-center p-4"> + <div className="max-w-sm space-y-4 text-center"> + <div className="flex justify-center"> + <div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted"> + <FileX className="h-8 w-8 text-muted-foreground" /> + </div> + </div> + <div className="space-y-2"> + <h3 className="text-lg font-medium text-foreground"> + {t("preview.fetch_error_title")} + </h3> + <p className="text-sm leading-relaxed text-muted-foreground"> + {t("preview.fetch_error_description")} + </p> + </div> + </div> + </div> ); } else { content = ( diff --git a/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx b/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx index 8faca013..28bf690d 100644 --- a/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx +++ b/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx @@ -19,6 +19,7 @@ import { ChevronDown, ChevronRight, FileType, + Heading, Link, PlusCircle, Rss, @@ -28,7 +29,10 @@ import { } from "lucide-react"; import { useTranslation } from "react-i18next"; -import type { RuleEngineCondition } from "@karakeep/shared/types/rules"; +import type { + RuleEngineCondition, + RuleEngineEvent, +} from "@karakeep/shared/types/rules"; import { FeedSelector } from "../feeds/FeedSelector"; import { TagAutocomplete } from "../tags/TagAutocomplete"; @@ -36,6 +40,7 @@ import { TagAutocomplete } from "../tags/TagAutocomplete"; interface ConditionBuilderProps { value: RuleEngineCondition; onChange: (condition: RuleEngineCondition) => void; + eventType: RuleEngineEvent["type"]; level?: number; onRemove?: () => void; } @@ -43,6 +48,7 @@ interface ConditionBuilderProps { export function ConditionBuilder({ value, onChange, + eventType, level = 0, onRemove, }: ConditionBuilderProps) { @@ -54,6 +60,15 @@ export function ConditionBuilder({ case "urlContains": onChange({ type: "urlContains", str: "" }); break; + case "urlDoesNotContain": + onChange({ type: "urlDoesNotContain", str: "" }); + break; + case "titleContains": + onChange({ type: "titleContains", str: "" }); + break; + case "titleDoesNotContain": + onChange({ type: "titleDoesNotContain", str: "" }); + break; case "importedFromFeed": onChange({ type: "importedFromFeed", feedId: "" }); break; @@ -88,7 +103,11 @@ export function ConditionBuilder({ const renderConditionIcon = (type: RuleEngineCondition["type"]) => { switch (type) { case "urlContains": + case "urlDoesNotContain": return <Link className="h-4 w-4" />; + case "titleContains": + case "titleDoesNotContain": + return <Heading className="h-4 w-4" />; case "importedFromFeed": return <Rss className="h-4 w-4" />; case "bookmarkTypeIs": @@ -118,6 +137,42 @@ export function ConditionBuilder({ </div> ); + case "urlDoesNotContain": + return ( + <div className="mt-2"> + <Input + value={value.str} + onChange={(e) => onChange({ ...value, str: e.target.value })} + placeholder="URL does not contain..." + className="w-full" + /> + </div> + ); + + case "titleContains": + return ( + <div className="mt-2"> + <Input + value={value.str} + onChange={(e) => onChange({ ...value, str: e.target.value })} + placeholder="Title contains..." + className="w-full" + /> + </div> + ); + + case "titleDoesNotContain": + return ( + <div className="mt-2"> + <Input + value={value.str} + onChange={(e) => onChange({ ...value, str: e.target.value })} + placeholder="Title does not contain..." + className="w-full" + /> + </div> + ); + case "importedFromFeed": return ( <div className="mt-2"> @@ -182,6 +237,7 @@ export function ConditionBuilder({ newConditions[index] = newCondition; onChange({ ...value, conditions: newConditions }); }} + eventType={eventType} level={level + 1} onRemove={() => { const newConditions = [...value.conditions]; @@ -217,6 +273,10 @@ export function ConditionBuilder({ } }; + // Title conditions are hidden for "bookmarkAdded" event because + // titles are not available at bookmark creation time (they're fetched during crawling) + const showTitleConditions = eventType !== "bookmarkAdded"; + const ConditionSelector = () => ( <Select value={value.type} onValueChange={handleTypeChange}> <SelectTrigger className="ml-2 h-8 border-none bg-transparent px-2"> @@ -235,6 +295,19 @@ export function ConditionBuilder({ <SelectItem value="urlContains"> {t("settings.rules.conditions_types.url_contains")} </SelectItem> + <SelectItem value="urlDoesNotContain"> + {t("settings.rules.conditions_types.url_does_not_contain")} + </SelectItem> + {showTitleConditions && ( + <SelectItem value="titleContains"> + {t("settings.rules.conditions_types.title_contains")} + </SelectItem> + )} + {showTitleConditions && ( + <SelectItem value="titleDoesNotContain"> + {t("settings.rules.conditions_types.title_does_not_contain")} + </SelectItem> + )} <SelectItem value="importedFromFeed"> {t("settings.rules.conditions_types.imported_from_feed")} </SelectItem> diff --git a/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx b/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx index da10317a..e4859b4a 100644 --- a/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx +++ b/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx @@ -8,8 +8,8 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { toast } from "@/components/ui/sonner"; import { Textarea } from "@/components/ui/textarea"; -import { toast } from "@/components/ui/use-toast"; import { Save, X } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -175,6 +175,7 @@ export function RuleEditor({ rule, onCancel }: RuleEditorProps) { <ConditionBuilder value={editedRule.condition} onChange={handleConditionChange} + eventType={editedRule.event.type} /> </div> diff --git a/apps/web/components/dashboard/rules/RuleEngineRuleList.tsx b/apps/web/components/dashboard/rules/RuleEngineRuleList.tsx index 206a3550..32262b31 100644 --- a/apps/web/components/dashboard/rules/RuleEngineRuleList.tsx +++ b/apps/web/components/dashboard/rules/RuleEngineRuleList.tsx @@ -2,8 +2,8 @@ import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { toast } from "@/components/ui/sonner"; import { Switch } from "@/components/ui/switch"; -import { toast } from "@/components/ui/use-toast"; import { useClientConfig } from "@/lib/clientConfig"; import { Edit, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx index 15facb2d..4d3a690b 100644 --- a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx +++ b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx @@ -208,6 +208,17 @@ export default function QueryExplainerTooltip({ </TableCell> </TableRow> ); + case "source": + return ( + <TableRow> + <TableCell> + {matcher.inverse + ? t("search.is_not_from_source") + : t("search.is_from_source")} + </TableCell> + <TableCell>{matcher.source}</TableCell> + </TableRow> + ); default: { const _exhaustiveCheck: never = matcher; return null; diff --git a/apps/web/components/dashboard/search/useSearchAutocomplete.ts b/apps/web/components/dashboard/search/useSearchAutocomplete.ts index ba55d51f..c72f4fc5 100644 --- a/apps/web/components/dashboard/search/useSearchAutocomplete.ts +++ b/apps/web/components/dashboard/search/useSearchAutocomplete.ts @@ -2,8 +2,9 @@ import type translation from "@/lib/i18n/locales/en/translation.json"; import type { TFunction } from "i18next"; import type { LucideIcon } from "lucide-react"; import { useCallback, useMemo } from "react"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; import { + Globe, History, ListTree, RssIcon, @@ -14,6 +15,8 @@ import { import { useBookmarkLists } from "@karakeep/shared-react/hooks/lists"; import { useTagAutocomplete } from "@karakeep/shared-react/hooks/tags"; import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; +import { useTRPC } from "@karakeep/shared-react/trpc"; +import { zBookmarkSourceSchema } from "@karakeep/shared/types/bookmarks"; const MAX_DISPLAY_SUGGESTIONS = 5; @@ -97,10 +100,14 @@ const QUALIFIER_DEFINITIONS = [ value: "age:", descriptionKey: "search.created_within", }, + { + value: "source:", + descriptionKey: "search.is_from_source", + }, ] satisfies ReadonlyArray<QualifierDefinition>; export interface AutocompleteSuggestionItem { - type: "token" | "tag" | "list" | "feed"; + type: "token" | "tag" | "list" | "feed" | "source"; id: string; label: string; insertText: string; @@ -263,6 +270,7 @@ const useTagSuggestions = ( const { data: tagResults } = useTagAutocomplete({ nameContains: debouncedTagSearchTerm, select: (data) => data.tags, + enabled: parsed.activeToken.length > 0, }); const tagSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => { @@ -292,6 +300,7 @@ const useTagSuggestions = ( const useFeedSuggestions = ( parsed: ParsedSearchState, ): AutocompleteSuggestionItem[] => { + const api = useTRPC(); const shouldSuggestFeeds = parsed.normalizedTokenWithoutMinus.startsWith("feed:"); const feedSearchTermRaw = shouldSuggestFeeds @@ -299,7 +308,11 @@ const useFeedSuggestions = ( : ""; const feedSearchTerm = stripSurroundingQuotes(feedSearchTermRaw); const normalizedFeedSearchTerm = feedSearchTerm.toLowerCase(); - const { data: feedResults } = api.feeds.list.useQuery(); + const { data: feedResults } = useQuery( + api.feeds.list.queryOptions(undefined, { + enabled: parsed.activeToken.length > 0, + }), + ); const feedSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => { if (!shouldSuggestFeeds) { @@ -349,7 +362,9 @@ const useListSuggestions = ( : ""; const listSearchTerm = stripSurroundingQuotes(listSearchTermRaw); const normalizedListSearchTerm = listSearchTerm.toLowerCase(); - const { data: listResults } = useBookmarkLists(); + const { data: listResults } = useBookmarkLists(undefined, { + enabled: parsed.activeToken.length > 0, + }); const listSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => { if (!shouldSuggestLists) { @@ -357,6 +372,7 @@ const useListSuggestions = ( } const lists = listResults?.data ?? []; + const seenListNames = new Set<string>(); return lists .filter((list) => { @@ -365,6 +381,15 @@ const useListSuggestions = ( } return list.name.toLowerCase().includes(normalizedListSearchTerm); }) + .filter((list) => { + const normalizedListName = list.name.trim().toLowerCase(); + if (seenListNames.has(normalizedListName)) { + return false; + } + + seenListNames.add(normalizedListName); + return true; + }) .slice(0, MAX_DISPLAY_SUGGESTIONS) .map((list) => { const formattedName = formatSearchValue(list.name); @@ -389,12 +414,53 @@ const useListSuggestions = ( return listSuggestions; }; +const SOURCE_VALUES = zBookmarkSourceSchema.options; + +const useSourceSuggestions = ( + parsed: ParsedSearchState, +): AutocompleteSuggestionItem[] => { + const shouldSuggestSources = + parsed.normalizedTokenWithoutMinus.startsWith("source:"); + const sourceSearchTerm = shouldSuggestSources + ? parsed.normalizedTokenWithoutMinus.slice("source:".length) + : ""; + + const sourceSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => { + if (!shouldSuggestSources) { + return []; + } + + return SOURCE_VALUES.filter((source) => { + if (sourceSearchTerm.length === 0) { + return true; + } + return source.startsWith(sourceSearchTerm); + }) + .slice(0, MAX_DISPLAY_SUGGESTIONS) + .map((source) => { + const insertText = `${parsed.isTokenNegative ? "-" : ""}source:${source}`; + return { + type: "source" as const, + id: `source-${source}`, + label: insertText, + insertText, + appendSpace: true, + description: undefined, + Icon: Globe, + } satisfies AutocompleteSuggestionItem; + }); + }, [shouldSuggestSources, sourceSearchTerm, parsed.isTokenNegative]); + + return sourceSuggestions; +}; + const useHistorySuggestions = ( value: string, history: string[], ): HistorySuggestionItem[] => { const historyItems = useMemo<HistorySuggestionItem[]>(() => { const trimmedValue = value.trim(); + const seenTerms = new Set<string>(); const results = trimmedValue.length === 0 ? history @@ -402,16 +468,27 @@ const useHistorySuggestions = ( item.toLowerCase().includes(trimmedValue.toLowerCase()), ); - return results.slice(0, MAX_DISPLAY_SUGGESTIONS).map( - (term) => - ({ - type: "history" as const, - id: `history-${term}`, - term, - label: term, - Icon: History, - }) satisfies HistorySuggestionItem, - ); + return results + .filter((term) => { + const normalizedTerm = term.trim().toLowerCase(); + if (seenTerms.has(normalizedTerm)) { + return false; + } + + seenTerms.add(normalizedTerm); + return true; + }) + .slice(0, MAX_DISPLAY_SUGGESTIONS) + .map( + (term) => + ({ + type: "history" as const, + id: `history-${term}`, + term, + label: term, + Icon: History, + }) satisfies HistorySuggestionItem, + ); }, [history, value]); return historyItems; @@ -431,6 +508,7 @@ export const useSearchAutocomplete = ({ const tagSuggestions = useTagSuggestions(parsedState); const listSuggestions = useListSuggestions(parsedState); const feedSuggestions = useFeedSuggestions(parsedState); + const sourceSuggestions = useSourceSuggestions(parsedState); const historyItems = useHistorySuggestions(value, history); const { activeToken, getActiveToken } = parsedState; @@ -461,6 +539,14 @@ export const useSearchAutocomplete = ({ }); } + if (sourceSuggestions.length > 0) { + groups.push({ + id: "sources", + label: t("search.is_from_source"), + items: sourceSuggestions, + }); + } + // Only suggest qualifiers if no other suggestions are available if (groups.length === 0 && qualifierSuggestions.length > 0) { groups.push({ @@ -484,6 +570,7 @@ export const useSearchAutocomplete = ({ tagSuggestions, listSuggestions, feedSuggestions, + sourceSuggestions, historyItems, t, ]); diff --git a/apps/web/components/dashboard/sidebar/AllLists.tsx b/apps/web/components/dashboard/sidebar/AllLists.tsx index 306bf4b4..d1099231 100644 --- a/apps/web/components/dashboard/sidebar/AllLists.tsx +++ b/apps/web/components/dashboard/sidebar/AllLists.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import SidebarItem from "@/components/shared/sidebar/SidebarItem"; @@ -10,6 +10,8 @@ import { CollapsibleContent, CollapsibleTriggerTriangle, } from "@/components/ui/collapsible"; +import { toast } from "@/components/ui/sonner"; +import { BOOKMARK_DRAG_MIME } from "@/lib/bookmark-drag"; import { useTranslation } from "@/lib/i18n/client"; import { cn } from "@/lib/utils"; import { MoreHorizontal, Plus } from "lucide-react"; @@ -17,6 +19,7 @@ import { MoreHorizontal, Plus } from "lucide-react"; import type { ZBookmarkList } from "@karakeep/shared/types/lists"; import { augmentBookmarkListsWithInitialData, + useAddBookmarkToList, useBookmarkLists, } from "@karakeep/shared-react/hooks/lists"; import { ZBookmarkListTreeNode } from "@karakeep/shared/utils/listUtils"; @@ -26,6 +29,146 @@ import { EditListModal } from "../lists/EditListModal"; import { ListOptions } from "../lists/ListOptions"; import { InvitationNotificationBadge } from "./InvitationNotificationBadge"; +function useDropTarget(listId: string, listName: string) { + const { mutateAsync: addToList } = useAddBookmarkToList(); + const [dropHighlight, setDropHighlight] = useState(false); + const dragCounterRef = useRef(0); + const { t } = useTranslation(); + + const onDragOver = useCallback((e: React.DragEvent) => { + if (e.dataTransfer.types.includes(BOOKMARK_DRAG_MIME)) { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + } + }, []); + + const onDragEnter = useCallback((e: React.DragEvent) => { + if (e.dataTransfer.types.includes(BOOKMARK_DRAG_MIME)) { + e.preventDefault(); + dragCounterRef.current++; + setDropHighlight(true); + } + }, []); + + const onDragLeave = useCallback(() => { + dragCounterRef.current--; + if (dragCounterRef.current <= 0) { + dragCounterRef.current = 0; + setDropHighlight(false); + } + }, []); + + const onDrop = useCallback( + async (e: React.DragEvent) => { + dragCounterRef.current = 0; + setDropHighlight(false); + const bookmarkId = e.dataTransfer.getData(BOOKMARK_DRAG_MIME); + if (!bookmarkId) return; + e.preventDefault(); + try { + await addToList({ bookmarkId, listId }); + toast({ + description: t("lists.add_to_list_success", { + list: listName, + defaultValue: `Added to "${listName}"`, + }), + }); + } catch { + toast({ + description: t("common.something_went_wrong", { + defaultValue: "Something went wrong", + }), + variant: "destructive", + }); + } + }, + [addToList, listId, listName, t], + ); + + return { dropHighlight, onDragOver, onDragEnter, onDragLeave, onDrop }; +} + +function DroppableListSidebarItem({ + node, + level, + open, + numBookmarks, + selectedListId, + setSelectedListId, +}: { + node: ZBookmarkListTreeNode; + level: number; + open: boolean; + numBookmarks?: number; + selectedListId: string | null; + setSelectedListId: (id: string | null) => void; +}) { + const canDrop = + node.item.type === "manual" && + (node.item.userRole === "owner" || node.item.userRole === "editor"); + const { dropHighlight, onDragOver, onDragEnter, onDragLeave, onDrop } = + useDropTarget(node.item.id, node.item.name); + + return ( + <SidebarItem + collapseButton={ + node.children.length > 0 && ( + <CollapsibleTriggerTriangle + className="absolute left-0.5 top-1/2 size-2 -translate-y-1/2" + open={open} + /> + ) + } + logo={ + <span className="flex"> + <span className="text-lg"> {node.item.icon}</span> + </span> + } + name={node.item.name} + path={`/dashboard/lists/${node.item.id}`} + className="group px-0.5" + right={ + <ListOptions + onOpenChange={(isOpen) => { + if (isOpen) { + setSelectedListId(node.item.id); + } else { + setSelectedListId(null); + } + }} + list={node.item} + > + <Button size="none" variant="ghost" className="relative"> + <MoreHorizontal + className={cn( + "absolute inset-0 m-auto size-4 opacity-0 transition-opacity duration-100 group-hover:opacity-100", + selectedListId == node.item.id ? "opacity-100" : "opacity-0", + )} + /> + <span + className={cn( + "px-2.5 text-xs font-light text-muted-foreground opacity-100 transition-opacity duration-100 group-hover:opacity-0", + selectedListId == node.item.id || numBookmarks === undefined + ? "opacity-0" + : "opacity-100", + )} + > + {numBookmarks} + </span> + </Button> + </ListOptions> + } + linkClassName="py-0.5" + style={{ marginLeft: `${level * 1}rem` }} + dropHighlight={canDrop && dropHighlight} + onDragOver={canDrop ? onDragOver : undefined} + onDragEnter={canDrop ? onDragEnter : undefined} + onDragLeave={canDrop ? onDragLeave : undefined} + onDrop={canDrop ? onDrop : undefined} + /> + ); +} + export default function AllLists({ initialData, }: { @@ -71,7 +214,7 @@ export default function AllLists({ }, [isViewingSharedList, sharedListsOpen]); return ( - <ul className="max-h-full gap-y-2 overflow-auto text-sm"> + <ul className="sidebar-scrollbar max-h-full gap-y-2 overflow-auto text-sm"> <li className="flex justify-between pb-3"> <p className="text-xs uppercase tracking-wider text-muted-foreground"> Lists @@ -107,59 +250,13 @@ export default function AllLists({ filter={(node) => node.item.userRole === "owner"} isOpenFunc={isNodeOpen} render={({ node, level, open, numBookmarks }) => ( - <SidebarItem - collapseButton={ - node.children.length > 0 && ( - <CollapsibleTriggerTriangle - className="absolute left-0.5 top-1/2 size-2 -translate-y-1/2" - open={open} - /> - ) - } - logo={ - <span className="flex"> - <span className="text-lg"> {node.item.icon}</span> - </span> - } - name={node.item.name} - path={`/dashboard/lists/${node.item.id}`} - className="group px-0.5" - right={ - <ListOptions - onOpenChange={(isOpen) => { - if (isOpen) { - setSelectedListId(node.item.id); - } else { - setSelectedListId(null); - } - }} - list={node.item} - > - <Button size="none" variant="ghost" className="relative"> - <MoreHorizontal - className={cn( - "absolute inset-0 m-auto size-4 opacity-0 transition-opacity duration-100 group-hover:opacity-100", - selectedListId == node.item.id - ? "opacity-100" - : "opacity-0", - )} - /> - <span - className={cn( - "px-2.5 text-xs font-light text-muted-foreground opacity-100 transition-opacity duration-100 group-hover:opacity-0", - selectedListId == node.item.id || - numBookmarks === undefined - ? "opacity-0" - : "opacity-100", - )} - > - {numBookmarks} - </span> - </Button> - </ListOptions> - } - linkClassName="py-0.5" - style={{ marginLeft: `${level * 1}rem` }} + <DroppableListSidebarItem + node={node} + level={level} + open={open} + numBookmarks={numBookmarks} + selectedListId={selectedListId} + setSelectedListId={setSelectedListId} /> )} /> @@ -187,59 +284,13 @@ export default function AllLists({ isOpenFunc={isNodeOpen} indentOffset={1} render={({ node, level, open, numBookmarks }) => ( - <SidebarItem - collapseButton={ - node.children.length > 0 && ( - <CollapsibleTriggerTriangle - className="absolute left-0.5 top-1/2 size-2 -translate-y-1/2" - open={open} - /> - ) - } - logo={ - <span className="flex"> - <span className="text-lg"> {node.item.icon}</span> - </span> - } - name={node.item.name} - path={`/dashboard/lists/${node.item.id}`} - className="group px-0.5" - right={ - <ListOptions - onOpenChange={(isOpen) => { - if (isOpen) { - setSelectedListId(node.item.id); - } else { - setSelectedListId(null); - } - }} - list={node.item} - > - <Button size="none" variant="ghost" className="relative"> - <MoreHorizontal - className={cn( - "absolute inset-0 m-auto size-4 opacity-0 transition-opacity duration-100 group-hover:opacity-100", - selectedListId == node.item.id - ? "opacity-100" - : "opacity-0", - )} - /> - <span - className={cn( - "px-2.5 text-xs font-light text-muted-foreground opacity-100 transition-opacity duration-100 group-hover:opacity-0", - selectedListId == node.item.id || - numBookmarks === undefined - ? "opacity-0" - : "opacity-100", - )} - > - {numBookmarks} - </span> - </Button> - </ListOptions> - } - linkClassName="py-0.5" - style={{ marginLeft: `${level * 1}rem` }} + <DroppableListSidebarItem + node={node} + level={level} + open={open} + numBookmarks={numBookmarks} + selectedListId={selectedListId} + setSelectedListId={setSelectedListId} /> )} /> diff --git a/apps/web/components/dashboard/sidebar/InvitationNotificationBadge.tsx b/apps/web/components/dashboard/sidebar/InvitationNotificationBadge.tsx index e4d7b39f..e3c65be9 100644 --- a/apps/web/components/dashboard/sidebar/InvitationNotificationBadge.tsx +++ b/apps/web/components/dashboard/sidebar/InvitationNotificationBadge.tsx @@ -1,13 +1,15 @@ "use client"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; export function InvitationNotificationBadge() { - const { data: pendingInvitations } = api.lists.getPendingInvitations.useQuery( - undefined, - { + const api = useTRPC(); + const { data: pendingInvitations } = useQuery( + api.lists.getPendingInvitations.queryOptions(undefined, { refetchInterval: 1000 * 60 * 5, - }, + }), ); const pendingInvitationsCount = pendingInvitations?.length ?? 0; diff --git a/apps/web/components/dashboard/tags/AllTagsView.tsx b/apps/web/components/dashboard/tags/AllTagsView.tsx index c21f9aac..9708c37f 100644 --- a/apps/web/components/dashboard/tags/AllTagsView.tsx +++ b/apps/web/components/dashboard/tags/AllTagsView.tsx @@ -22,9 +22,9 @@ import { import InfoTooltip from "@/components/ui/info-tooltip"; import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; +import { toast } from "@/components/ui/sonner"; import Spinner from "@/components/ui/spinner"; import { Toggle } from "@/components/ui/toggle"; -import { toast } from "@/components/ui/use-toast"; import useBulkTagActionsStore from "@/lib/bulkTagActions"; import { useTranslation } from "@/lib/i18n/client"; import { ArrowDownAZ, ChevronDown, Combine, Search, Tag } from "lucide-react"; diff --git a/apps/web/components/dashboard/tags/BulkTagAction.tsx b/apps/web/components/dashboard/tags/BulkTagAction.tsx index fbd044e0..c8061a1f 100644 --- a/apps/web/components/dashboard/tags/BulkTagAction.tsx +++ b/apps/web/components/dashboard/tags/BulkTagAction.tsx @@ -4,8 +4,8 @@ import { useEffect, useState } from "react"; import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; import { ButtonWithTooltip } from "@/components/ui/button"; +import { toast } from "@/components/ui/sonner"; import { Toggle } from "@/components/ui/toggle"; -import { useToast } from "@/components/ui/use-toast"; import useBulkTagActionsStore from "@/lib/bulkTagActions"; import { useTranslation } from "@/lib/i18n/client"; import { CheckCheck, Pencil, Trash2, X } from "lucide-react"; @@ -17,7 +17,6 @@ const MAX_CONCURRENT_BULK_ACTIONS = 50; export default function BulkTagAction() { const { t } = useTranslation(); - const { toast } = useToast(); const { selectedTagIds, diff --git a/apps/web/components/dashboard/tags/CreateTagModal.tsx b/apps/web/components/dashboard/tags/CreateTagModal.tsx index 3a4c4995..e5cf4a45 100644 --- a/apps/web/components/dashboard/tags/CreateTagModal.tsx +++ b/apps/web/components/dashboard/tags/CreateTagModal.tsx @@ -22,7 +22,7 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { Plus } from "lucide-react"; diff --git a/apps/web/components/dashboard/tags/DeleteTagConfirmationDialog.tsx b/apps/web/components/dashboard/tags/DeleteTagConfirmationDialog.tsx index 0a589ee6..7df04e20 100644 --- a/apps/web/components/dashboard/tags/DeleteTagConfirmationDialog.tsx +++ b/apps/web/components/dashboard/tags/DeleteTagConfirmationDialog.tsx @@ -1,7 +1,7 @@ import { usePathname, useRouter } from "next/navigation"; import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useDeleteTag } from "@karakeep/shared-react/hooks/tags"; diff --git a/apps/web/components/dashboard/tags/EditableTagName.tsx b/apps/web/components/dashboard/tags/EditableTagName.tsx index 7854be32..e6df5086 100644 --- a/apps/web/components/dashboard/tags/EditableTagName.tsx +++ b/apps/web/components/dashboard/tags/EditableTagName.tsx @@ -1,7 +1,7 @@ "use client"; import { usePathname, useRouter } from "next/navigation"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { cn } from "@/lib/utils"; import { useUpdateTag } from "@karakeep/shared-react/hooks/tags"; diff --git a/apps/web/components/dashboard/tags/MergeTagModal.tsx b/apps/web/components/dashboard/tags/MergeTagModal.tsx index 84dcd478..22b07c98 100644 --- a/apps/web/components/dashboard/tags/MergeTagModal.tsx +++ b/apps/web/components/dashboard/tags/MergeTagModal.tsx @@ -18,7 +18,7 @@ import { FormItem, FormMessage, } from "@/components/ui/form"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; diff --git a/apps/web/components/dashboard/tags/TagAutocomplete.tsx b/apps/web/components/dashboard/tags/TagAutocomplete.tsx index 8164dc81..656d4c5a 100644 --- a/apps/web/components/dashboard/tags/TagAutocomplete.tsx +++ b/apps/web/components/dashboard/tags/TagAutocomplete.tsx @@ -15,11 +15,12 @@ import { } from "@/components/ui/popover"; import LoadingSpinner from "@/components/ui/spinner"; import { cn } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; import { Check, ChevronsUpDown, X } from "lucide-react"; import { useTagAutocomplete } from "@karakeep/shared-react/hooks/tags"; import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; -import { api } from "@karakeep/shared-react/trpc"; +import { useTRPC } from "@karakeep/shared-react/trpc"; interface TagAutocompleteProps { tagId: string; @@ -32,6 +33,7 @@ export function TagAutocomplete({ onChange, className, }: TagAutocompleteProps) { + const api = useTRPC(); const [open, setOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const searchQueryDebounced = useDebounce(searchQuery, 500); @@ -41,8 +43,8 @@ export function TagAutocomplete({ select: (data) => data.tags, }); - const { data: selectedTag, isLoading: isSelectedTagLoading } = - api.tags.get.useQuery( + const { data: selectedTag, isLoading: isSelectedTagLoading } = useQuery( + api.tags.get.queryOptions( { tagId, }, @@ -53,7 +55,8 @@ export function TagAutocomplete({ }), enabled: !!tagId, }, - ); + ), + ); const handleSelect = (currentValue: string) => { setOpen(false); diff --git a/apps/web/components/dashboard/tags/TagPill.tsx b/apps/web/components/dashboard/tags/TagPill.tsx index 65a42e08..09310f9f 100644 --- a/apps/web/components/dashboard/tags/TagPill.tsx +++ b/apps/web/components/dashboard/tags/TagPill.tsx @@ -2,7 +2,7 @@ import React, { useRef, useState } from "react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useDragAndDrop } from "@/lib/drag-and-drop"; import { X } from "lucide-react"; import Draggable from "react-draggable"; diff --git a/apps/web/components/invite/InviteAcceptForm.tsx b/apps/web/components/invite/InviteAcceptForm.tsx index 95a0e1eb..eb1fa5c9 100644 --- a/apps/web/components/invite/InviteAcceptForm.tsx +++ b/apps/web/components/invite/InviteAcceptForm.tsx @@ -21,14 +21,16 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { api } from "@/lib/trpc"; +import { signIn } from "@/lib/auth/client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { TRPCClientError } from "@trpc/client"; import { AlertCircle, Clock, Loader2, Mail, UserPlus } from "lucide-react"; -import { signIn } from "next-auth/react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + const inviteAcceptSchema = z .object({ name: z.string().min(1, "Name is required"), @@ -47,6 +49,7 @@ interface InviteAcceptFormProps { } export default function InviteAcceptForm({ token }: InviteAcceptFormProps) { + const api = useTRPC(); const router = useRouter(); const form = useForm<z.infer<typeof inviteAcceptSchema>>({ @@ -59,7 +62,7 @@ export default function InviteAcceptForm({ token }: InviteAcceptFormProps) { isPending: loading, data: inviteData, error, - } = api.invites.get.useQuery({ token }); + } = useQuery(api.invites.get.queryOptions({ token })); useEffect(() => { if (error) { @@ -67,7 +70,9 @@ export default function InviteAcceptForm({ token }: InviteAcceptFormProps) { } }, [error]); - const acceptInviteMutation = api.invites.accept.useMutation(); + const acceptInviteMutation = useMutation( + api.invites.accept.mutationOptions(), + ); const handleBackToSignIn = () => { router.push("/signin"); diff --git a/apps/web/components/public/lists/PublicBookmarkGrid.tsx b/apps/web/components/public/lists/PublicBookmarkGrid.tsx index d6aa9875..742d7e6e 100644 --- a/apps/web/components/public/lists/PublicBookmarkGrid.tsx +++ b/apps/web/components/public/lists/PublicBookmarkGrid.tsx @@ -9,14 +9,15 @@ import { ActionButton } from "@/components/ui/action-button"; import { badgeVariants } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; -import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; import tailwindConfig from "@/tailwind.config"; +import { useInfiniteQuery } from "@tanstack/react-query"; import { Expand, FileIcon, ImageIcon } from "lucide-react"; import { useInView } from "react-intersection-observer"; import Masonry from "react-masonry-css"; import resolveConfig from "tailwindcss/resolveConfig"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes, ZPublicBookmark, @@ -199,19 +200,22 @@ export default function PublicBookmarkGrid({ bookmarks: ZPublicBookmark[]; nextCursor: ZCursor | null; }) { + const api = useTRPC(); const { ref: loadMoreRef, inView: loadMoreButtonInView } = useInView(); const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = - api.publicBookmarks.getPublicBookmarksInList.useInfiniteQuery( - { listId: list.id }, - { - initialData: () => ({ - pages: [{ bookmarks: initialBookmarks, nextCursor, list }], - pageParams: [null], - }), - initialCursor: null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - refetchOnMount: true, - }, + useInfiniteQuery( + api.publicBookmarks.getPublicBookmarksInList.infiniteQueryOptions( + { listId: list.id }, + { + initialData: () => ({ + pages: [{ bookmarks: initialBookmarks, nextCursor, list }], + pageParams: [null], + }), + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + refetchOnMount: true, + }, + ), ); useEffect(() => { @@ -227,7 +231,11 @@ export default function PublicBookmarkGrid({ }, [data]); return ( <> - <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> + <Masonry + className="-ml-4 flex w-auto" + columnClassName="pl-4" + breakpointCols={breakpointConfig} + > {bookmarks.map((bookmark) => ( <BookmarkCard key={bookmark.id} bookmark={bookmark} /> ))} diff --git a/apps/web/components/settings/AISettings.tsx b/apps/web/components/settings/AISettings.tsx index beaa93dc..6d28f4f8 100644 --- a/apps/web/components/settings/AISettings.tsx +++ b/apps/web/components/settings/AISettings.tsx @@ -1,6 +1,25 @@ "use client"; +import React from "react"; +import { TagsEditor } from "@/components/dashboard/bookmarks/TagsEditor"; import { ActionButton } from "@/components/ui/action-button"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Field, + FieldContent, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, + FieldTitle, +} from "@/components/ui/field"; import { Form, FormControl, @@ -10,6 +29,7 @@ import { } from "@/components/ui/form"; import { FullPageSpinner } from "@/components/ui/full-page-spinner"; import { Input } from "@/components/ui/input"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Select, SelectContent, @@ -18,15 +38,22 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; +import { Switch } from "@/components/ui/switch"; import { useClientConfig } from "@/lib/clientConfig"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useUserSettings } from "@/lib/userSettings"; +import { cn } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Plus, Save, Trash2 } from "lucide-react"; -import { useForm } from "react-hook-form"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Info, Plus, Save, Trash2 } from "lucide-react"; +import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; +import type { ZBookmarkTags } from "@karakeep/shared/types/tags"; +import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; +import { useUpdateUserSettings } from "@karakeep/shared-react/hooks/users"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { buildImagePrompt, buildSummaryPromptUntruncated, @@ -37,10 +64,426 @@ import { ZPrompt, zUpdatePromptSchema, } from "@karakeep/shared/types/prompts"; +import { zUpdateUserSettingsSchema } from "@karakeep/shared/types/users"; + +function SettingsSection({ + title, + description, + children, +}: { + title?: string; + description?: string; + children: React.ReactNode; + className?: string; +}) { + return ( + <Card> + <CardHeader> + {title && <CardTitle>{title}</CardTitle>} + {description && <CardDescription>{description}</CardDescription>} + </CardHeader> + <CardContent>{children}</CardContent> + </Card> + ); +} + +export function AIPreferences() { + const { t } = useTranslation(); + const clientConfig = useClientConfig(); + const settings = useUserSettings(); + + const { mutate: updateSettings, isPending } = useUpdateUserSettings({ + onSuccess: () => { + toast({ + description: "Settings updated successfully!", + }); + }, + onError: () => { + toast({ + description: "Failed to update settings", + variant: "destructive", + }); + }, + }); + + const form = useForm<z.infer<typeof zUpdateUserSettingsSchema>>({ + resolver: zodResolver(zUpdateUserSettingsSchema), + values: settings + ? { + inferredTagLang: settings.inferredTagLang ?? "", + autoTaggingEnabled: settings.autoTaggingEnabled, + autoSummarizationEnabled: settings.autoSummarizationEnabled, + } + : undefined, + }); + + const showAutoTagging = clientConfig.inference.enableAutoTagging; + const showAutoSummarization = clientConfig.inference.enableAutoSummarization; + + const onSubmit = (data: z.infer<typeof zUpdateUserSettingsSchema>) => { + updateSettings(data); + }; + + return ( + <SettingsSection title="AI preferences"> + <form onSubmit={form.handleSubmit(onSubmit)}> + <FieldGroup className="gap-3"> + <Controller + name="inferredTagLang" + control={form.control} + render={({ field, fieldState }) => ( + <Field + className="rounded-lg border p-3" + data-invalid={fieldState.invalid} + > + <FieldContent> + <FieldLabel htmlFor="inferredTagLang"> + {t("settings.ai.inference_language")} + </FieldLabel> + <FieldDescription> + {t("settings.ai.inference_language_description")} + </FieldDescription> + </FieldContent> + <Input + {...field} + id="inferredTagLang" + value={field.value ?? ""} + onChange={(e) => + field.onChange( + e.target.value.length > 0 ? e.target.value : null, + ) + } + aria-invalid={fieldState.invalid} + placeholder={`Default (${clientConfig.inference.inferredTagLang})`} + type="text" + /> + {fieldState.invalid && ( + <FieldError errors={[fieldState.error]} /> + )} + </Field> + )} + /> + + {showAutoTagging && ( + <Controller + name="autoTaggingEnabled" + control={form.control} + render={({ field, fieldState }) => ( + <Field + orientation="horizontal" + className="rounded-lg border p-3" + data-invalid={fieldState.invalid} + > + <FieldContent> + <FieldLabel htmlFor="autoTaggingEnabled"> + {t("settings.ai.auto_tagging")} + </FieldLabel> + <FieldDescription> + {t("settings.ai.auto_tagging_description")} + </FieldDescription> + </FieldContent> + <Switch + id="autoTaggingEnabled" + name={field.name} + checked={field.value ?? true} + onCheckedChange={field.onChange} + aria-invalid={fieldState.invalid} + /> + {fieldState.invalid && ( + <FieldError errors={[fieldState.error]} /> + )} + </Field> + )} + /> + )} + + {showAutoSummarization && ( + <Controller + name="autoSummarizationEnabled" + control={form.control} + render={({ field, fieldState }) => ( + <Field + orientation="horizontal" + className="rounded-lg border p-3" + data-invalid={fieldState.invalid} + > + <FieldContent> + <FieldLabel htmlFor="autoSummarizationEnabled"> + {t("settings.ai.auto_summarization")} + </FieldLabel> + <FieldDescription> + {t("settings.ai.auto_summarization_description")} + </FieldDescription> + </FieldContent> + <Switch + id="autoSummarizationEnabled" + name={field.name} + checked={field.value ?? true} + onCheckedChange={field.onChange} + aria-invalid={fieldState.invalid} + /> + {fieldState.invalid && ( + <FieldError errors={[fieldState.error]} /> + )} + </Field> + )} + /> + )} + + <div className="flex justify-end pt-4"> + <ActionButton type="submit" loading={isPending} variant="default"> + <Save className="mr-2 size-4" /> + {t("actions.save")} + </ActionButton> + </div> + </FieldGroup> + </form> + </SettingsSection> + ); +} + +export function TagStyleSelector() { + const { t } = useTranslation(); + const settings = useUserSettings(); + + const { mutate: updateSettings, isPending: isUpdating } = + useUpdateUserSettings({ + onSuccess: () => { + toast({ + description: "Tag style updated successfully!", + }); + }, + onError: () => { + toast({ + description: "Failed to update tag style", + variant: "destructive", + }); + }, + }); + + const tagStyleOptions = [ + { + value: "lowercase-hyphens", + label: t("settings.ai.lowercase_hyphens"), + examples: ["machine-learning", "web-development"], + }, + { + value: "lowercase-spaces", + label: t("settings.ai.lowercase_spaces"), + examples: ["machine learning", "web development"], + }, + { + value: "lowercase-underscores", + label: t("settings.ai.lowercase_underscores"), + examples: ["machine_learning", "web_development"], + }, + { + value: "titlecase-spaces", + label: t("settings.ai.titlecase_spaces"), + examples: ["Machine Learning", "Web Development"], + }, + { + value: "titlecase-hyphens", + label: t("settings.ai.titlecase_hyphens"), + examples: ["Machine-Learning", "Web-Development"], + }, + { + value: "camelCase", + label: t("settings.ai.camelCase"), + examples: ["machineLearning", "webDevelopment"], + }, + { + value: "as-generated", + label: t("settings.ai.no_preference"), + examples: ["Machine Learning", "web development", "AI_generated"], + }, + ] as const; + + const selectedStyle = settings?.tagStyle ?? "as-generated"; + + return ( + <SettingsSection + title={t("settings.ai.tag_style")} + description={t("settings.ai.tag_style_description")} + > + <RadioGroup + value={selectedStyle} + onValueChange={(value) => { + updateSettings({ tagStyle: value as typeof selectedStyle }); + }} + disabled={isUpdating} + className="grid gap-3 sm:grid-cols-2" + > + {tagStyleOptions.map((option) => ( + <FieldLabel + key={option.value} + htmlFor={option.value} + className={cn(selectedStyle === option.value && "ring-1")} + > + <Field orientation="horizontal"> + <FieldContent> + <FieldTitle>{option.label}</FieldTitle> + <div className="flex flex-wrap gap-1"> + {option.examples.map((example) => ( + <Badge + key={example} + variant="secondary" + className="text-xs font-light" + > + {example} + </Badge> + ))} + </div> + </FieldContent> + <RadioGroupItem value={option.value} id={option.value} /> + </Field> + </FieldLabel> + ))} + </RadioGroup> + </SettingsSection> + ); +} + +export function CuratedTagsSelector() { + const api = useTRPC(); + const { t } = useTranslation(); + const settings = useUserSettings(); + + const { mutate: updateSettings, isPending: isUpdatingCuratedTags } = + useUpdateUserSettings({ + onSuccess: () => { + toast({ + description: t("settings.ai.curated_tags_updated"), + }); + }, + onError: () => { + toast({ + description: t("settings.ai.curated_tags_update_failed"), + variant: "destructive", + }); + }, + }); + + const areTagIdsEqual = React.useCallback((a: string[], b: string[]) => { + return a.length === b.length && a.every((id, index) => id === b[index]); + }, []); + + const curatedTagIds = React.useMemo( + () => settings?.curatedTagIds ?? [], + [settings?.curatedTagIds], + ); + const [localCuratedTagIds, setLocalCuratedTagIds] = + React.useState<string[]>(curatedTagIds); + const debouncedCuratedTagIds = useDebounce(localCuratedTagIds, 300); + const lastServerCuratedTagIdsRef = React.useRef(curatedTagIds); + const lastSubmittedCuratedTagIdsRef = React.useRef<string[] | null>(null); + + React.useEffect(() => { + const hadUnsyncedLocalChanges = !areTagIdsEqual( + localCuratedTagIds, + lastServerCuratedTagIdsRef.current, + ); + + if ( + !hadUnsyncedLocalChanges && + !areTagIdsEqual(localCuratedTagIds, curatedTagIds) + ) { + setLocalCuratedTagIds(curatedTagIds); + } + + lastServerCuratedTagIdsRef.current = curatedTagIds; + }, [areTagIdsEqual, curatedTagIds, localCuratedTagIds]); + + React.useEffect(() => { + if (isUpdatingCuratedTags) { + return; + } + + if (areTagIdsEqual(debouncedCuratedTagIds, curatedTagIds)) { + lastSubmittedCuratedTagIdsRef.current = null; + return; + } + + if ( + lastSubmittedCuratedTagIdsRef.current && + areTagIdsEqual( + lastSubmittedCuratedTagIdsRef.current, + debouncedCuratedTagIds, + ) + ) { + return; + } + + lastSubmittedCuratedTagIdsRef.current = debouncedCuratedTagIds; + updateSettings({ + curatedTagIds: + debouncedCuratedTagIds.length > 0 ? debouncedCuratedTagIds : null, + }); + }, [ + areTagIdsEqual, + curatedTagIds, + debouncedCuratedTagIds, + isUpdatingCuratedTags, + updateSettings, + ]); + + // Fetch selected tags to display their names + const { data: selectedTagsData } = useQuery( + api.tags.list.queryOptions( + { ids: localCuratedTagIds }, + { enabled: localCuratedTagIds.length > 0 }, + ), + ); + + const selectedTags: ZBookmarkTags[] = React.useMemo(() => { + const tagsMap = new Map( + (selectedTagsData?.tags ?? []).map((tag) => [tag.id, tag]), + ); + // Preserve the order from curatedTagIds instead of server sort order + return localCuratedTagIds + .map((id) => tagsMap.get(id)) + .filter((tag): tag is NonNullable<typeof tag> => tag != null) + .map((tag) => ({ + id: tag.id, + name: tag.name, + attachedBy: "human" as const, + })); + }, [selectedTagsData?.tags, localCuratedTagIds]); + + return ( + <SettingsSection + title={t("settings.ai.curated_tags")} + description={t("settings.ai.curated_tags_description")} + > + <TagsEditor + tags={selectedTags} + placeholder="Select curated tags..." + onAttach={(tag) => { + const tagId = tag.tagId; + if (tagId) { + setLocalCuratedTagIds((prev) => { + if (prev.includes(tagId)) { + return prev; + } + return [...prev, tagId]; + }); + } + }} + onDetach={(tag) => { + setLocalCuratedTagIds((prev) => { + return prev.filter((id) => id !== tag.tagId); + }); + }} + allowCreation={false} + /> + </SettingsSection> + ); +} export function PromptEditor() { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); const form = useForm<z.infer<typeof zNewPromptSchema>>({ resolver: zodResolver(zNewPromptSchema), @@ -50,15 +493,16 @@ export function PromptEditor() { }, }); - const { mutateAsync: createPrompt, isPending: isCreating } = - api.prompts.create.useMutation({ + const { mutateAsync: createPrompt, isPending: isCreating } = useMutation( + api.prompts.create.mutationOptions({ onSuccess: () => { toast({ description: "Prompt has been created!", }); - apiUtils.prompts.list.invalidate(); + queryClient.invalidateQueries(api.prompts.list.pathFilter()); }, - }); + }), + ); return ( <Form {...form}> @@ -140,26 +584,29 @@ export function PromptEditor() { } export function PromptRow({ prompt }: { prompt: ZPrompt }) { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); - const { mutateAsync: updatePrompt, isPending: isUpdating } = - api.prompts.update.useMutation({ + const queryClient = useQueryClient(); + const { mutateAsync: updatePrompt, isPending: isUpdating } = useMutation( + api.prompts.update.mutationOptions({ onSuccess: () => { toast({ description: "Prompt has been updated!", }); - apiUtils.prompts.list.invalidate(); + queryClient.invalidateQueries(api.prompts.list.pathFilter()); }, - }); - const { mutate: deletePrompt, isPending: isDeleting } = - api.prompts.delete.useMutation({ + }), + ); + const { mutate: deletePrompt, isPending: isDeleting } = useMutation( + api.prompts.delete.mutationOptions({ onSuccess: () => { toast({ description: "Prompt has been deleted!", }); - apiUtils.prompts.list.invalidate(); + queryClient.invalidateQueries(api.prompts.list.pathFilter()); }, - }); + }), + ); const form = useForm<z.infer<typeof zUpdatePromptSchema>>({ resolver: zodResolver(zUpdatePromptSchema), @@ -273,92 +720,144 @@ export function PromptRow({ prompt }: { prompt: ZPrompt }) { } export function TaggingRules() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: prompts, isLoading } = api.prompts.list.useQuery(); + const { data: prompts, isLoading } = useQuery( + api.prompts.list.queryOptions(), + ); return ( - <div className="mt-2 flex flex-col gap-2"> - <div className="w-full text-xl font-medium sm:w-1/3"> - {t("settings.ai.tagging_rules")} - </div> - <p className="mb-1 text-xs italic text-muted-foreground"> - {t("settings.ai.tagging_rule_description")} - </p> - {isLoading && <FullPageSpinner />} + <SettingsSection + title={t("settings.ai.tagging_rules")} + description={t("settings.ai.tagging_rule_description")} + > {prompts && prompts.length == 0 && ( - <p className="rounded-md bg-muted p-2 text-sm text-muted-foreground"> - You don't have any custom prompts yet. - </p> + <div className="flex items-start gap-2 rounded-md bg-muted p-4 text-sm text-muted-foreground"> + <Info className="size-4 flex-shrink-0" /> + <p>You don't have any custom prompts yet.</p> + </div> )} - {prompts && - prompts.map((prompt) => <PromptRow key={prompt.id} prompt={prompt} />)} - <PromptEditor /> - </div> + <div className="flex flex-col gap-2"> + {isLoading && <FullPageSpinner />} + {prompts && + prompts.map((prompt) => ( + <PromptRow key={prompt.id} prompt={prompt} /> + ))} + <PromptEditor /> + </div> + </SettingsSection> ); } export function PromptDemo() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: prompts } = api.prompts.list.useQuery(); + const { data: prompts } = useQuery(api.prompts.list.queryOptions()); + const settings = useUserSettings(); const clientConfig = useClientConfig(); + const tagStyle = settings?.tagStyle ?? "as-generated"; + const curatedTagIds = settings?.curatedTagIds ?? []; + const { data: tagsData } = useQuery( + api.tags.list.queryOptions( + { ids: curatedTagIds }, + { enabled: curatedTagIds.length > 0 }, + ), + ); + const inferredTagLang = + settings?.inferredTagLang ?? clientConfig.inference.inferredTagLang; + + // Resolve curated tag names for preview + const curatedTagNames = + curatedTagIds.length > 0 && tagsData?.tags + ? curatedTagIds + .map((id) => tagsData.tags.find((tag) => tag.id === id)?.name) + .filter((name): name is string => Boolean(name)) + : undefined; + return ( - <div className="flex flex-col gap-2"> - <div className="mb-4 w-full text-xl font-medium sm:w-1/3"> - {t("settings.ai.prompt_preview")} + <SettingsSection + title={t("settings.ai.prompt_preview")} + description="Preview the actual prompts sent to AI based on your settings" + > + <div className="space-y-4"> + <div> + <p className="mb-2 text-sm font-medium"> + {t("settings.ai.text_prompt")} + </p> + <code className="block whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground"> + {buildTextPromptUntruncated( + inferredTagLang, + (prompts ?? []) + .filter( + (p) => p.appliesTo == "text" || p.appliesTo == "all_tagging", + ) + .map((p) => p.text), + "\n<CONTENT_HERE>\n", + tagStyle, + curatedTagNames, + ).trim()} + </code> + </div> + <div> + <p className="mb-2 text-sm font-medium"> + {t("settings.ai.images_prompt")} + </p> + <code className="block whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground"> + {buildImagePrompt( + inferredTagLang, + (prompts ?? []) + .filter( + (p) => + p.appliesTo == "images" || p.appliesTo == "all_tagging", + ) + .map((p) => p.text), + tagStyle, + curatedTagNames, + ).trim()} + </code> + </div> + <div> + <p className="mb-2 text-sm font-medium"> + {t("settings.ai.summarization_prompt")} + </p> + <code className="block whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground"> + {buildSummaryPromptUntruncated( + inferredTagLang, + (prompts ?? []) + .filter((p) => p.appliesTo == "summary") + .map((p) => p.text), + "\n<CONTENT_HERE>\n", + ).trim()} + </code> + </div> </div> - <p>{t("settings.ai.text_prompt")}</p> - <code className="whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground"> - {buildTextPromptUntruncated( - clientConfig.inference.inferredTagLang, - (prompts ?? []) - .filter( - (p) => p.appliesTo == "text" || p.appliesTo == "all_tagging", - ) - .map((p) => p.text), - "\n<CONTENT_HERE>\n", - ).trim()} - </code> - <p>{t("settings.ai.images_prompt")}</p> - <code className="whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground"> - {buildImagePrompt( - clientConfig.inference.inferredTagLang, - (prompts ?? []) - .filter( - (p) => p.appliesTo == "images" || p.appliesTo == "all_tagging", - ) - .map((p) => p.text), - ).trim()} - </code> - <p>{t("settings.ai.summarization_prompt")}</p> - <code className="whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground"> - {buildSummaryPromptUntruncated( - clientConfig.inference.inferredTagLang, - (prompts ?? []) - .filter((p) => p.appliesTo == "summary") - .map((p) => p.text), - "\n<CONTENT_HERE>\n", - ).trim()} - </code> - </div> + </SettingsSection> ); } export default function AISettings() { const { t } = useTranslation(); return ( - <> - <div className="rounded-md border bg-background p-4"> - <div className="mb-2 flex flex-col gap-3"> - <div className="w-full text-2xl font-medium sm:w-1/3"> - {t("settings.ai.ai_settings")} - </div> - <TaggingRules /> - </div> - </div> - <div className="mt-4 rounded-md border bg-background p-4"> - <PromptDemo /> - </div> - </> + <div className="space-y-6"> + <h2 className="text-3xl font-bold tracking-tight"> + {t("settings.ai.ai_settings")} + </h2> + + {/* AI Preferences */} + <AIPreferences /> + + {/* Tag Style */} + <TagStyleSelector /> + + {/* Curated Tags */} + <CuratedTagsSelector /> + + {/* Tagging Rules */} + <TaggingRules /> + + {/* Prompt Preview */} + <PromptDemo /> + </div> ); } diff --git a/apps/web/components/settings/AddApiKey.tsx b/apps/web/components/settings/AddApiKey.tsx index c8baa626..b6612a51 100644 --- a/apps/web/components/settings/AddApiKey.tsx +++ b/apps/web/components/settings/AddApiKey.tsx @@ -24,34 +24,39 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; import { PlusCircle } from "lucide-react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + import ApiKeySuccess from "./ApiKeySuccess"; function AddApiKeyForm({ onSuccess }: { onSuccess: (key: string) => void }) { + const api = useTRPC(); const { t } = useTranslation(); const formSchema = z.object({ name: z.string(), }); const router = useRouter(); - const mutator = api.apiKeys.create.useMutation({ - onSuccess: (resp) => { - onSuccess(resp.key); - router.refresh(); - }, - onError: () => { - toast({ - description: t("common.something_went_wrong"), - variant: "destructive", - }); - }, - }); + const mutator = useMutation( + api.apiKeys.create.mutationOptions({ + onSuccess: (resp) => { + onSuccess(resp.key); + router.refresh(); + }, + onError: () => { + toast({ + description: t("common.something_went_wrong"), + variant: "destructive", + }); + }, + }), + ); const form = useForm<z.infer<typeof formSchema>>({ resolver: zodResolver(formSchema), diff --git a/apps/web/components/settings/ApiKeySettings.tsx b/apps/web/components/settings/ApiKeySettings.tsx index bc4b71c5..fa8b4927 100644 --- a/apps/web/components/settings/ApiKeySettings.tsx +++ b/apps/web/components/settings/ApiKeySettings.tsx @@ -8,6 +8,7 @@ import { } from "@/components/ui/table"; import { useTranslation } from "@/lib/i18n/server"; import { api } from "@/server/api/client"; +import { formatDistanceToNow } from "date-fns"; import AddApiKey from "./AddApiKey"; import DeleteApiKey from "./DeleteApiKey"; @@ -32,23 +33,33 @@ export default async function ApiKeys() { <TableHead>{t("common.name")}</TableHead> <TableHead>{t("common.key")}</TableHead> <TableHead>{t("common.created_at")}</TableHead> + <TableHead>{t("common.last_used")}</TableHead> <TableHead>{t("common.action")}</TableHead> </TableRow> </TableHeader> <TableBody> - {keys.keys.map((k) => ( - <TableRow key={k.id}> - <TableCell>{k.name}</TableCell> - <TableCell>**_{k.keyId}_**</TableCell> - <TableCell>{k.createdAt.toLocaleString()}</TableCell> - <TableCell> - <div className="flex items-center gap-2"> - <RegenerateApiKey name={k.name} id={k.id} /> - <DeleteApiKey name={k.name} id={k.id} /> - </div> - </TableCell> - </TableRow> - ))} + {keys.keys.map((key) => { + return ( + <TableRow key={key.id}> + <TableCell>{key.name}</TableCell> + <TableCell>**_{key.keyId}_**</TableCell> + <TableCell> + {formatDistanceToNow(key.createdAt, { addSuffix: true })} + </TableCell> + <TableCell> + {key.lastUsedAt + ? formatDistanceToNow(key.lastUsedAt, { addSuffix: true }) + : "—"} + </TableCell> + <TableCell> + <div className="flex items-center gap-2"> + <RegenerateApiKey name={key.name} id={key.id} /> + <DeleteApiKey name={key.name} id={key.id} /> + </div> + </TableCell> + </TableRow> + ); + })} <TableRow></TableRow> </TableBody> </Table> diff --git a/apps/web/components/settings/BackupSettings.tsx b/apps/web/components/settings/BackupSettings.tsx index 18a80993..57672fb0 100644 --- a/apps/web/components/settings/BackupSettings.tsx +++ b/apps/web/components/settings/BackupSettings.tsx @@ -21,12 +21,12 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { toast } from "@/components/ui/sonner"; import { Switch } from "@/components/ui/switch"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { useUserSettings } from "@/lib/userSettings"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CheckCircle, Download, @@ -39,6 +39,7 @@ import { useForm } from "react-hook-form"; import { z } from "zod"; import { useUpdateUserSettings } from "@karakeep/shared-react/hooks/users"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { zBackupSchema } from "@karakeep/shared/types/backups"; import { zUpdateBackupSettingsSchema } from "@karakeep/shared/types/users"; import { getAssetUrl } from "@karakeep/shared/utils/assetUtils"; @@ -207,16 +208,17 @@ function BackupConfigurationForm() { } function BackupRow({ backup }: { backup: z.infer<typeof zBackupSchema> }) { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); - const { mutate: deleteBackup, isPending: isDeleting } = - api.backups.delete.useMutation({ + const { mutate: deleteBackup, isPending: isDeleting } = useMutation( + api.backups.delete.mutationOptions({ onSuccess: () => { toast({ description: t("settings.backups.toasts.backup_deleted"), }); - apiUtils.backups.list.invalidate(); + queryClient.invalidateQueries(api.backups.list.pathFilter()); }, onError: (error) => { toast({ @@ -224,7 +226,8 @@ function BackupRow({ backup }: { backup: z.infer<typeof zBackupSchema> }) { variant: "destructive", }); }, - }); + }), + ); const formatSize = (bytes: number) => { if (bytes < 1024) return `${bytes} B`; @@ -330,25 +333,28 @@ function BackupRow({ backup }: { backup: z.infer<typeof zBackupSchema> }) { } function BackupsList() { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); - const { data: backups, isLoading } = api.backups.list.useQuery(undefined, { - refetchInterval: (query) => { - const data = query.state.data; - // Poll every 3 seconds if there's a pending backup, otherwise don't poll - return data?.backups.some((backup) => backup.status === "pending") - ? 3000 - : false; - }, - }); + const queryClient = useQueryClient(); + const { data: backups, isLoading } = useQuery( + api.backups.list.queryOptions(undefined, { + refetchInterval: (query) => { + const data = query.state.data; + // Poll every 3 seconds if there's a pending backup, otherwise don't poll + return data?.backups.some((backup) => backup.status === "pending") + ? 3000 + : false; + }, + }), + ); - const { mutate: triggerBackup, isPending: isTriggering } = - api.backups.triggerBackup.useMutation({ + const { mutate: triggerBackup, isPending: isTriggering } = useMutation( + api.backups.triggerBackup.mutationOptions({ onSuccess: () => { toast({ description: t("settings.backups.toasts.backup_queued"), }); - apiUtils.backups.list.invalidate(); + queryClient.invalidateQueries(api.backups.list.pathFilter()); }, onError: (error) => { toast({ @@ -356,7 +362,8 @@ function BackupsList() { variant: "destructive", }); }, - }); + }), + ); return ( <div className="rounded-md border bg-background p-4"> diff --git a/apps/web/components/settings/ChangePassword.tsx b/apps/web/components/settings/ChangePassword.tsx index a27741d9..481d4b95 100644 --- a/apps/web/components/settings/ChangePassword.tsx +++ b/apps/web/components/settings/ChangePassword.tsx @@ -12,19 +12,21 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; import { Eye, EyeOff, Lock } from "lucide-react"; import { useForm } from "react-hook-form"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { zChangePasswordSchema } from "@karakeep/shared/types/users"; import { Button } from "../ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; export function ChangePassword() { + const api = useTRPC(); const { t } = useTranslation(); const [showCurrentPassword, setShowCurrentPassword] = useState(false); const [showNewPassword, setShowNewPassword] = useState(false); @@ -38,22 +40,27 @@ export function ChangePassword() { }, }); - const mutator = api.users.changePassword.useMutation({ - onSuccess: () => { - toast({ description: "Password changed successfully" }); - form.reset(); - }, - onError: (e) => { - if (e.data?.code == "UNAUTHORIZED") { - toast({ - description: "Your current password is incorrect", - variant: "destructive", - }); - } else { - toast({ description: "Something went wrong", variant: "destructive" }); - } - }, - }); + const mutator = useMutation( + api.users.changePassword.mutationOptions({ + onSuccess: () => { + toast({ description: "Password changed successfully" }); + form.reset(); + }, + onError: (e) => { + if (e.data?.code == "UNAUTHORIZED") { + toast({ + description: "Your current password is incorrect", + variant: "destructive", + }); + } else { + toast({ + description: "Something went wrong", + variant: "destructive", + }); + } + }, + }), + ); async function onSubmit(value: z.infer<typeof zChangePasswordSchema>) { mutator.mutate({ diff --git a/apps/web/components/settings/DeleteAccount.tsx b/apps/web/components/settings/DeleteAccount.tsx index 6ebafff9..5ccbfaf7 100644 --- a/apps/web/components/settings/DeleteAccount.tsx +++ b/apps/web/components/settings/DeleteAccount.tsx @@ -13,7 +13,7 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { zodResolver } from "@hookform/resolvers/zod"; import { AlertTriangle, Eye, EyeOff, Trash2 } from "lucide-react"; import { useForm } from "react-hook-form"; diff --git a/apps/web/components/settings/DeleteApiKey.tsx b/apps/web/components/settings/DeleteApiKey.tsx index 4efb7ea8..b4cf7eea 100644 --- a/apps/web/components/settings/DeleteApiKey.tsx +++ b/apps/web/components/settings/DeleteApiKey.tsx @@ -4,10 +4,12 @@ import { useRouter } from "next/navigation"; import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; import { Button } from "@/components/ui/button"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useMutation } from "@tanstack/react-query"; import { Trash } from "lucide-react"; +import { toast } from "sonner"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; export default function DeleteApiKey({ name, @@ -16,16 +18,17 @@ export default function DeleteApiKey({ name: string; id: string; }) { + const api = useTRPC(); const { t } = useTranslation(); const router = useRouter(); - const mutator = api.apiKeys.revoke.useMutation({ - onSuccess: () => { - toast({ - description: "Key was successfully deleted", - }); - router.refresh(); - }, - }); + const mutator = useMutation( + api.apiKeys.revoke.mutationOptions({ + onSuccess: () => { + toast.success("Key was successfully deleted"); + router.refresh(); + }, + }), + ); return ( <ActionConfirmingDialog @@ -49,8 +52,8 @@ export default function DeleteApiKey({ </ActionButton> )} > - <Button variant="outline"> - <Trash size={18} color="red" /> + <Button variant="ghost" title={t("actions.delete")}> + <Trash size={18} /> </Button> </ActionConfirmingDialog> ); diff --git a/apps/web/components/settings/FeedSettings.tsx b/apps/web/components/settings/FeedSettings.tsx index 23b639e4..ba1568a7 100644 --- a/apps/web/components/settings/FeedSettings.tsx +++ b/apps/web/components/settings/FeedSettings.tsx @@ -13,12 +13,12 @@ import { } from "@/components/ui/form"; import { FullPageSpinner } from "@/components/ui/full-page-spinner"; import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/sonner"; import { Switch } from "@/components/ui/switch"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowDownToLine, CheckCircle, @@ -33,6 +33,7 @@ import { import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { ZFeed, zNewFeedSchema, @@ -61,9 +62,10 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; export function FeedsEditorDialog() { + const api = useTRPC(); const { t } = useTranslation(); const [open, setOpen] = React.useState(false); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); const form = useForm<z.infer<typeof zNewFeedSchema>>({ resolver: zodResolver(zNewFeedSchema), @@ -81,16 +83,17 @@ export function FeedsEditorDialog() { } }, [open]); - const { mutateAsync: createFeed, isPending: isCreating } = - api.feeds.create.useMutation({ + const { mutateAsync: createFeed, isPending: isCreating } = useMutation( + api.feeds.create.mutationOptions({ onSuccess: () => { toast({ description: "Feed has been created!", }); - apiUtils.feeds.list.invalidate(); + queryClient.invalidateQueries(api.feeds.list.pathFilter()); setOpen(false); }, - }); + }), + ); return ( <Dialog open={open} onOpenChange={setOpen}> @@ -191,8 +194,9 @@ export function FeedsEditorDialog() { } export function EditFeedDialog({ feed }: { feed: ZFeed }) { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); const [open, setOpen] = React.useState(false); React.useEffect(() => { if (open) { @@ -204,16 +208,17 @@ export function EditFeedDialog({ feed }: { feed: ZFeed }) { }); } }, [open]); - const { mutateAsync: updateFeed, isPending: isUpdating } = - api.feeds.update.useMutation({ + const { mutateAsync: updateFeed, isPending: isUpdating } = useMutation( + api.feeds.update.mutationOptions({ onSuccess: () => { toast({ description: "Feed has been updated!", }); setOpen(false); - apiUtils.feeds.list.invalidate(); + queryClient.invalidateQueries(api.feeds.list.pathFilter()); }, - }); + }), + ); const form = useForm<z.infer<typeof zUpdateFeedSchema>>({ resolver: zodResolver(zUpdateFeedSchema), defaultValues: { @@ -339,44 +344,49 @@ export function EditFeedDialog({ feed }: { feed: ZFeed }) { } export function FeedRow({ feed }: { feed: ZFeed }) { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); - const { mutate: deleteFeed, isPending: isDeleting } = - api.feeds.delete.useMutation({ + const queryClient = useQueryClient(); + const { mutate: deleteFeed, isPending: isDeleting } = useMutation( + api.feeds.delete.mutationOptions({ onSuccess: () => { toast({ description: "Feed has been deleted!", }); - apiUtils.feeds.list.invalidate(); + queryClient.invalidateQueries(api.feeds.list.pathFilter()); }, - }); + }), + ); - const { mutate: fetchNow, isPending: isFetching } = - api.feeds.fetchNow.useMutation({ + const { mutate: fetchNow, isPending: isFetching } = useMutation( + api.feeds.fetchNow.mutationOptions({ onSuccess: () => { toast({ description: "Feed fetch has been enqueued!", }); - apiUtils.feeds.list.invalidate(); + queryClient.invalidateQueries(api.feeds.list.pathFilter()); }, - }); + }), + ); - const { mutate: updateFeedEnabled } = api.feeds.update.useMutation({ - onSuccess: () => { - toast({ - description: feed.enabled - ? t("settings.feeds.feed_disabled") - : t("settings.feeds.feed_enabled"), - }); - apiUtils.feeds.list.invalidate(); - }, - onError: (error) => { - toast({ - description: `Error: ${error.message}`, - variant: "destructive", - }); - }, - }); + const { mutate: updateFeedEnabled } = useMutation( + api.feeds.update.mutationOptions({ + onSuccess: () => { + toast({ + description: feed.enabled + ? t("settings.feeds.feed_disabled") + : t("settings.feeds.feed_enabled"), + }); + queryClient.invalidateQueries(api.feeds.list.pathFilter()); + }, + onError: (error) => { + toast({ + description: `Error: ${error.message}`, + variant: "destructive", + }); + }, + }), + ); const handleToggle = (checked: boolean) => { updateFeedEnabled({ feedId: feed.id, enabled: checked }); @@ -456,8 +466,9 @@ export function FeedRow({ feed }: { feed: ZFeed }) { } export default function FeedSettings() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: feeds, isLoading } = api.feeds.list.useQuery(); + const { data: feeds, isLoading } = useQuery(api.feeds.list.queryOptions()); return ( <> <div className="rounded-md border bg-background p-4"> diff --git a/apps/web/components/settings/ImportExport.tsx b/apps/web/components/settings/ImportExport.tsx index b6e4da9a..e02297c9 100644 --- a/apps/web/components/settings/ImportExport.tsx +++ b/apps/web/components/settings/ImportExport.tsx @@ -12,6 +12,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { toast } from "@/components/ui/sonner"; import { useBookmarkImport } from "@/lib/hooks/useBookmarkImport"; import { useTranslation } from "@/lib/i18n/client"; import { cn } from "@/lib/utils"; @@ -19,7 +20,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircle, Download, Loader2, Upload } from "lucide-react"; import { Card, CardContent } from "../ui/card"; -import { toast } from "../ui/use-toast"; import { ImportSessionsSection } from "./ImportSessionsSection"; function ImportCard({ @@ -180,6 +180,23 @@ export function ImportExportRow() { </FilePickerButton> </ImportCard> <ImportCard + text="Matter" + description={t("settings.import.import_bookmarks_from_matter_export")} + > + <FilePickerButton + size={"sm"} + loading={false} + accept=".csv" + multiple={false} + className="flex items-center gap-2" + onFileSelect={(file) => + runUploadBookmarkFile({ file, source: "matter" }) + } + > + <p>Import</p> + </FilePickerButton> + </ImportCard> + <ImportCard text="Omnivore" description={t( "settings.import.import_bookmarks_from_omnivore_export", @@ -254,6 +271,25 @@ export function ImportExportRow() { </FilePickerButton> </ImportCard> <ImportCard + text="Instapaper" + description={t( + "settings.import.import_bookmarks_from_instapaper_export", + )} + > + <FilePickerButton + size={"sm"} + loading={false} + accept=".csv" + multiple={false} + className="flex items-center gap-2" + onFileSelect={(file) => + runUploadBookmarkFile({ file, source: "instapaper" }) + } + > + <p>Import</p> + </FilePickerButton> + </ImportCard> + <ImportCard text="Karakeep" description={t( "settings.import.import_bookmarks_from_karakeep_export", diff --git a/apps/web/components/settings/ImportSessionCard.tsx b/apps/web/components/settings/ImportSessionCard.tsx index 690caaa5..f62a00dd 100644 --- a/apps/web/components/settings/ImportSessionCard.tsx +++ b/apps/web/components/settings/ImportSessionCard.tsx @@ -9,6 +9,8 @@ import { Progress } from "@/components/ui/progress"; import { useDeleteImportSession, useImportSessionStats, + usePauseImportSession, + useResumeImportSession, } from "@/lib/hooks/useImportSessions"; import { useTranslation } from "@/lib/i18n/client"; import { formatDistanceToNow } from "date-fns"; @@ -19,10 +21,17 @@ import { Clock, ExternalLink, Loader2, + Pause, + Play, Trash2, + Upload, } from "lucide-react"; -import type { ZImportSessionWithStats } from "@karakeep/shared/types/importSessions"; +import type { + ZImportSessionStatus, + ZImportSessionWithStats, +} from "@karakeep/shared/types/importSessions"; +import { switchCase } from "@karakeep/shared/utils/switch"; interface ImportSessionCardProps { session: ZImportSessionWithStats; @@ -30,10 +39,14 @@ interface ImportSessionCardProps { function getStatusColor(status: string) { switch (status) { + case "staging": + return "bg-purple-500/10 text-purple-700 dark:text-purple-400"; case "pending": return "bg-muted text-muted-foreground"; - case "in_progress": + case "running": return "bg-blue-500/10 text-blue-700 dark:text-blue-400"; + case "paused": + return "bg-yellow-500/10 text-yellow-700 dark:text-yellow-400"; case "completed": return "bg-green-500/10 text-green-700 dark:text-green-400"; case "failed": @@ -45,10 +58,14 @@ function getStatusColor(status: string) { function getStatusIcon(status: string) { switch (status) { + case "staging": + return <Upload className="h-4 w-4" />; case "pending": return <Clock className="h-4 w-4" />; - case "in_progress": + case "running": return <Loader2 className="h-4 w-4 animate-spin" />; + case "paused": + return <Pause className="h-4 w-4" />; case "completed": return <CheckCircle2 className="h-4 w-4" />; case "failed": @@ -62,13 +79,18 @@ export function ImportSessionCard({ session }: ImportSessionCardProps) { const { t } = useTranslation(); const { data: liveStats } = useImportSessionStats(session.id); const deleteSession = useDeleteImportSession(); + const pauseSession = usePauseImportSession(); + const resumeSession = useResumeImportSession(); - const statusLabels: Record<string, string> = { - pending: t("settings.import_sessions.status.pending"), - in_progress: t("settings.import_sessions.status.in_progress"), - completed: t("settings.import_sessions.status.completed"), - failed: t("settings.import_sessions.status.failed"), - }; + const statusLabels = (s: ZImportSessionStatus) => + switchCase(s, { + staging: t("settings.import_sessions.status.staging"), + pending: t("settings.import_sessions.status.pending"), + running: t("settings.import_sessions.status.running"), + paused: t("settings.import_sessions.status.paused"), + completed: t("settings.import_sessions.status.completed"), + failed: t("settings.import_sessions.status.failed"), + }); // Use live stats if available, otherwise fallback to session stats const stats = liveStats || session; @@ -79,7 +101,14 @@ export function ImportSessionCard({ session }: ImportSessionCardProps) { 100 : 0; - const canDelete = stats.status !== "in_progress"; + const canDelete = + stats.status === "completed" || + stats.status === "failed" || + stats.status === "paused"; + + const canPause = stats.status === "pending" || stats.status === "running"; + + const canResume = stats.status === "paused"; return ( <Card className="transition-all hover:shadow-md"> @@ -101,7 +130,7 @@ export function ImportSessionCard({ session }: ImportSessionCardProps) { > {getStatusIcon(stats.status)} <span className="ml-1 capitalize"> - {statusLabels[stats.status] ?? stats.status.replace("_", " ")} + {statusLabels(stats.status)} </span> </Badge> </div> @@ -213,6 +242,38 @@ export function ImportSessionCard({ session }: ImportSessionCardProps) { {/* Actions */} <div className="flex items-center justify-end pt-2"> <div className="flex items-center gap-2"> + <Button variant="outline" size="sm" asChild> + <Link href={`/settings/import/${session.id}`}> + <ExternalLink className="mr-1 h-4 w-4" /> + {t("settings.import_sessions.view_details")} + </Link> + </Button> + {canPause && ( + <Button + variant="outline" + size="sm" + onClick={() => + pauseSession.mutate({ importSessionId: session.id }) + } + disabled={pauseSession.isPending} + > + <Pause className="mr-1 h-4 w-4" /> + {t("settings.import_sessions.pause_session")} + </Button> + )} + {canResume && ( + <Button + variant="outline" + size="sm" + onClick={() => + resumeSession.mutate({ importSessionId: session.id }) + } + disabled={resumeSession.isPending} + > + <Play className="mr-1 h-4 w-4" /> + {t("settings.import_sessions.resume_session")} + </Button> + )} {canDelete && ( <ActionConfirmingDialog title={t("settings.import_sessions.delete_dialog_title")} diff --git a/apps/web/components/settings/ImportSessionDetail.tsx b/apps/web/components/settings/ImportSessionDetail.tsx new file mode 100644 index 00000000..4b356eda --- /dev/null +++ b/apps/web/components/settings/ImportSessionDetail.tsx @@ -0,0 +1,596 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { ActionButton } from "@/components/ui/action-button"; +import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { FullPageSpinner } from "@/components/ui/full-page-spinner"; +import { Progress } from "@/components/ui/progress"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + useDeleteImportSession, + useImportSessionResults, + useImportSessionStats, + usePauseImportSession, + useResumeImportSession, +} from "@/lib/hooks/useImportSessions"; +import { useTranslation } from "@/lib/i18n/client"; +import { formatDistanceToNow } from "date-fns"; +import { + AlertCircle, + ArrowLeft, + CheckCircle2, + Clock, + ExternalLink, + FileText, + Globe, + Loader2, + Paperclip, + Pause, + Play, + Trash2, + Upload, +} from "lucide-react"; +import { useInView } from "react-intersection-observer"; + +import type { ZImportSessionStatus } from "@karakeep/shared/types/importSessions"; +import { switchCase } from "@karakeep/shared/utils/switch"; + +type FilterType = + | "all" + | "accepted" + | "rejected" + | "skipped_duplicate" + | "pending"; + +type SimpleTFunction = ( + key: string, + options?: Record<string, unknown>, +) => string; + +interface ImportSessionResultItem { + id: string; + title: string | null; + url: string | null; + content: string | null; + type: string; + status: string; + result: string | null; + resultReason: string | null; + resultBookmarkId: string | null; +} + +function getStatusColor(status: string) { + switch (status) { + case "staging": + return "bg-purple-500/10 text-purple-700 dark:text-purple-400"; + case "pending": + return "bg-muted text-muted-foreground"; + case "running": + return "bg-blue-500/10 text-blue-700 dark:text-blue-400"; + case "paused": + return "bg-yellow-500/10 text-yellow-700 dark:text-yellow-400"; + case "completed": + return "bg-green-500/10 text-green-700 dark:text-green-400"; + case "failed": + return "bg-destructive/10 text-destructive"; + default: + return "bg-muted text-muted-foreground"; + } +} + +function getStatusIcon(status: string) { + switch (status) { + case "staging": + return <Upload className="h-4 w-4" />; + case "pending": + return <Clock className="h-4 w-4" />; + case "running": + return <Loader2 className="h-4 w-4 animate-spin" />; + case "paused": + return <Pause className="h-4 w-4" />; + case "completed": + return <CheckCircle2 className="h-4 w-4" />; + case "failed": + return <AlertCircle className="h-4 w-4" />; + default: + return <Clock className="h-4 w-4" />; + } +} + +function getResultBadge( + status: string, + result: string | null, + t: (key: string) => string, +) { + if (status === "pending") { + return ( + <Badge + variant="secondary" + className="bg-muted text-muted-foreground hover:bg-muted" + > + <Clock className="mr-1 h-3 w-3" /> + {t("settings.import_sessions.detail.result_pending")} + </Badge> + ); + } + if (status === "processing") { + return ( + <Badge + variant="secondary" + className="bg-blue-500/10 text-blue-700 hover:bg-blue-500/10 dark:text-blue-400" + > + <Loader2 className="mr-1 h-3 w-3 animate-spin" /> + {t("settings.import_sessions.detail.result_processing")} + </Badge> + ); + } + switch (result) { + case "accepted": + return ( + <Badge + variant="secondary" + className="bg-green-500/10 text-green-700 hover:bg-green-500/10 dark:text-green-400" + > + <CheckCircle2 className="mr-1 h-3 w-3" /> + {t("settings.import_sessions.detail.result_accepted")} + </Badge> + ); + case "rejected": + return ( + <Badge + variant="secondary" + className="bg-destructive/10 text-destructive hover:bg-destructive/10" + > + <AlertCircle className="mr-1 h-3 w-3" /> + {t("settings.import_sessions.detail.result_rejected")} + </Badge> + ); + case "skipped_duplicate": + return ( + <Badge + variant="secondary" + className="bg-amber-500/10 text-amber-700 hover:bg-amber-500/10 dark:text-amber-400" + > + {t("settings.import_sessions.detail.result_skipped_duplicate")} + </Badge> + ); + default: + return ( + <Badge variant="secondary" className="bg-muted hover:bg-muted"> + — + </Badge> + ); + } +} + +function getTypeIcon(type: string) { + switch (type) { + case "link": + return <Globe className="h-3 w-3" />; + case "text": + return <FileText className="h-3 w-3" />; + case "asset": + return <Paperclip className="h-3 w-3" />; + default: + return null; + } +} + +function getTypeLabel(type: string, t: SimpleTFunction) { + switch (type) { + case "link": + return t("common.bookmark_types.link"); + case "text": + return t("common.bookmark_types.text"); + case "asset": + return t("common.bookmark_types.media"); + default: + return type; + } +} + +function getTitleDisplay( + item: { + title: string | null; + url: string | null; + content: string | null; + type: string; + }, + noTitleLabel: string, +) { + if (item.title) { + return item.title; + } + if (item.type === "text" && item.content) { + return item.content.length > 80 + ? item.content.substring(0, 80) + "…" + : item.content; + } + if (item.url) { + try { + const url = new URL(item.url); + const display = url.hostname + url.pathname; + return display.length > 60 ? display.substring(0, 60) + "…" : display; + } catch { + return item.url.length > 60 ? item.url.substring(0, 60) + "…" : item.url; + } + } + return noTitleLabel; +} + +export default function ImportSessionDetail({ + sessionId, +}: { + sessionId: string; +}) { + const { t: tRaw } = useTranslation(); + const t = tRaw as SimpleTFunction; + const router = useRouter(); + const [filter, setFilter] = useState<FilterType>("all"); + + const { data: stats, isLoading: isStatsLoading } = + useImportSessionStats(sessionId); + const { + data: resultsData, + isLoading: isResultsLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useImportSessionResults(sessionId, filter); + + const deleteSession = useDeleteImportSession(); + const pauseSession = usePauseImportSession(); + const resumeSession = useResumeImportSession(); + + const { ref: loadMoreRef, inView: loadMoreInView } = useInView(); + + useEffect(() => { + if (loadMoreInView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [fetchNextPage, hasNextPage, isFetchingNextPage, loadMoreInView]); + + if (isStatsLoading) { + return <FullPageSpinner />; + } + + if (!stats) { + return null; + } + + const items: ImportSessionResultItem[] = + resultsData?.pages.flatMap((page) => page.items) ?? []; + + const progress = + stats.totalBookmarks > 0 + ? ((stats.completedBookmarks + stats.failedBookmarks) / + stats.totalBookmarks) * + 100 + : 0; + + const canDelete = + stats.status === "completed" || + stats.status === "failed" || + stats.status === "paused"; + const canPause = stats.status === "pending" || stats.status === "running"; + const canResume = stats.status === "paused"; + + const statusLabels = (s: ZImportSessionStatus) => + switchCase(s, { + staging: t("settings.import_sessions.status.staging"), + pending: t("settings.import_sessions.status.pending"), + running: t("settings.import_sessions.status.running"), + paused: t("settings.import_sessions.status.paused"), + completed: t("settings.import_sessions.status.completed"), + failed: t("settings.import_sessions.status.failed"), + }); + + const handleDelete = () => { + deleteSession.mutateAsync({ importSessionId: sessionId }).then(() => { + router.push("/settings/import"); + }); + }; + + return ( + <div className="flex flex-col gap-6"> + {/* Back link */} + <Link + href="/settings/import" + className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground" + > + <ArrowLeft className="h-4 w-4" /> + {t("settings.import_sessions.detail.back_to_import")} + </Link> + + {/* Header */} + <div className="rounded-md border bg-background p-4"> + <div className="flex flex-col gap-4"> + <div className="flex items-start justify-between"> + <div className="flex-1"> + <h2 className="text-lg font-medium">{stats.name}</h2> + <p className="mt-1 text-sm text-muted-foreground"> + {t("settings.import_sessions.created_at", { + time: formatDistanceToNow(stats.createdAt, { + addSuffix: true, + }), + })} + </p> + </div> + <Badge + className={`${getStatusColor(stats.status)} hover:bg-inherit`} + > + {getStatusIcon(stats.status)} + <span className="ml-1 capitalize"> + {statusLabels(stats.status)} + </span> + </Badge> + </div> + + {/* Progress bar + stats */} + {stats.totalBookmarks > 0 && ( + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <h4 className="text-sm font-medium text-muted-foreground"> + {t("settings.import_sessions.progress")} + </h4> + <div className="flex items-center gap-2"> + <span className="text-sm font-medium"> + {stats.completedBookmarks + stats.failedBookmarks} /{" "} + {stats.totalBookmarks} + </span> + <Badge variant="outline" className="text-xs"> + {Math.round(progress)}% + </Badge> + </div> + </div> + <Progress value={progress} className="h-3" /> + <div className="flex flex-wrap gap-2"> + {stats.completedBookmarks > 0 && ( + <Badge + variant="secondary" + className="bg-green-500/10 text-green-700 hover:bg-green-500/10 dark:text-green-400" + > + <CheckCircle2 className="mr-1.5 h-3 w-3" /> + {t("settings.import_sessions.badges.completed", { + count: stats.completedBookmarks, + })} + </Badge> + )} + {stats.failedBookmarks > 0 && ( + <Badge + variant="secondary" + className="bg-destructive/10 text-destructive hover:bg-destructive/10" + > + <AlertCircle className="mr-1.5 h-3 w-3" /> + {t("settings.import_sessions.badges.failed", { + count: stats.failedBookmarks, + })} + </Badge> + )} + {stats.pendingBookmarks > 0 && ( + <Badge + variant="secondary" + className="bg-amber-500/10 text-amber-700 hover:bg-amber-500/10 dark:text-amber-400" + > + <Clock className="mr-1.5 h-3 w-3" /> + {t("settings.import_sessions.badges.pending", { + count: stats.pendingBookmarks, + })} + </Badge> + )} + {stats.processingBookmarks > 0 && ( + <Badge + variant="secondary" + className="bg-blue-500/10 text-blue-700 hover:bg-blue-500/10 dark:text-blue-400" + > + <Loader2 className="mr-1.5 h-3 w-3 animate-spin" /> + {t("settings.import_sessions.badges.processing", { + count: stats.processingBookmarks, + })} + </Badge> + )} + </div> + </div> + )} + + {/* Message */} + {stats.message && ( + <div className="rounded-lg border bg-muted/50 p-3 text-sm text-muted-foreground dark:bg-muted/20"> + {stats.message} + </div> + )} + + {/* Action buttons */} + <div className="flex items-center justify-end"> + <div className="flex items-center gap-2"> + {canPause && ( + <Button + variant="outline" + size="sm" + onClick={() => + pauseSession.mutate({ importSessionId: sessionId }) + } + disabled={pauseSession.isPending} + > + <Pause className="mr-1 h-4 w-4" /> + {t("settings.import_sessions.pause_session")} + </Button> + )} + {canResume && ( + <Button + variant="outline" + size="sm" + onClick={() => + resumeSession.mutate({ importSessionId: sessionId }) + } + disabled={resumeSession.isPending} + > + <Play className="mr-1 h-4 w-4" /> + {t("settings.import_sessions.resume_session")} + </Button> + )} + {canDelete && ( + <ActionConfirmingDialog + title={t("settings.import_sessions.delete_dialog_title")} + description={ + <div> + {t("settings.import_sessions.delete_dialog_description", { + name: stats.name, + })} + </div> + } + actionButton={(setDialogOpen) => ( + <Button + variant="destructive" + onClick={() => { + handleDelete(); + setDialogOpen(false); + }} + disabled={deleteSession.isPending} + > + {t("settings.import_sessions.delete_session")} + </Button> + )} + > + <Button + variant="destructive" + size="sm" + disabled={deleteSession.isPending} + > + <Trash2 className="mr-1 h-4 w-4" /> + {t("actions.delete")} + </Button> + </ActionConfirmingDialog> + )} + </div> + </div> + </div> + </div> + + {/* Filter tabs + Results table */} + <div className="rounded-md border bg-background p-4"> + <Tabs + value={filter} + onValueChange={(v) => setFilter(v as FilterType)} + className="w-full" + > + <TabsList className="mb-4 flex w-full flex-wrap"> + <TabsTrigger value="all"> + {t("settings.import_sessions.detail.filter_all")} + </TabsTrigger> + <TabsTrigger value="accepted"> + {t("settings.import_sessions.detail.filter_accepted")} + </TabsTrigger> + <TabsTrigger value="rejected"> + {t("settings.import_sessions.detail.filter_rejected")} + </TabsTrigger> + <TabsTrigger value="skipped_duplicate"> + {t("settings.import_sessions.detail.filter_duplicates")} + </TabsTrigger> + <TabsTrigger value="pending"> + {t("settings.import_sessions.detail.filter_pending")} + </TabsTrigger> + </TabsList> + </Tabs> + + {isResultsLoading ? ( + <FullPageSpinner /> + ) : items.length === 0 ? ( + <p className="rounded-md bg-muted p-4 text-center text-sm text-muted-foreground"> + {t("settings.import_sessions.detail.no_results")} + </p> + ) : ( + <div className="flex flex-col gap-2"> + <Table> + <TableHeader> + <TableRow> + <TableHead> + {t("settings.import_sessions.detail.table_title")} + </TableHead> + <TableHead className="w-[80px]"> + {t("settings.import_sessions.detail.table_type")} + </TableHead> + <TableHead className="w-[120px]"> + {t("settings.import_sessions.detail.table_result")} + </TableHead> + <TableHead> + {t("settings.import_sessions.detail.table_reason")} + </TableHead> + <TableHead className="w-[100px]"> + {t("settings.import_sessions.detail.table_bookmark")} + </TableHead> + </TableRow> + </TableHeader> + <TableBody> + {items.map((item) => ( + <TableRow key={item.id}> + <TableCell className="max-w-[300px] truncate font-medium"> + {getTitleDisplay( + item, + t("settings.import_sessions.detail.no_title"), + )} + </TableCell> + <TableCell> + <Badge + variant="outline" + className="flex w-fit items-center gap-1 text-xs" + > + {getTypeIcon(item.type)} + {getTypeLabel(item.type, t)} + </Badge> + </TableCell> + <TableCell> + {getResultBadge(item.status, item.result, t)} + </TableCell> + <TableCell className="max-w-[200px] truncate text-sm text-muted-foreground"> + {item.resultReason || "—"} + </TableCell> + <TableCell> + {item.resultBookmarkId ? ( + <Link + href={`/dashboard/preview/${item.resultBookmarkId}`} + className="flex items-center gap-1 text-sm text-primary hover:text-primary/80" + prefetch={false} + > + <ExternalLink className="h-3 w-3" /> + {t("settings.import_sessions.detail.view_bookmark")} + </Link> + ) : ( + <span className="text-sm text-muted-foreground">—</span> + )} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + {hasNextPage && ( + <div className="flex justify-center"> + <ActionButton + ref={loadMoreRef} + ignoreDemoMode={true} + loading={isFetchingNextPage} + onClick={() => fetchNextPage()} + variant="ghost" + > + {t("settings.import_sessions.detail.load_more")} + </ActionButton> + </div> + )} + </div> + )} + </div> + </div> + ); +} diff --git a/apps/web/components/settings/ReaderSettings.tsx b/apps/web/components/settings/ReaderSettings.tsx new file mode 100644 index 00000000..d694bf02 --- /dev/null +++ b/apps/web/components/settings/ReaderSettings.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "@/components/ui/sonner"; +import { useClientConfig } from "@/lib/clientConfig"; +import { useTranslation } from "@/lib/i18n/client"; +import { useReaderSettings } from "@/lib/readerSettings"; +import { + AlertTriangle, + BookOpen, + ChevronDown, + Laptop, + RotateCcw, +} from "lucide-react"; + +import { + formatFontSize, + formatLineHeight, + READER_DEFAULTS, + READER_FONT_FAMILIES, + READER_SETTING_CONSTRAINTS, +} from "@karakeep/shared/types/readers"; + +import { Alert, AlertDescription } from "../ui/alert"; +import { Button } from "../ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../ui/card"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "../ui/collapsible"; +import { Label } from "../ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import { Slider } from "../ui/slider"; + +export default function ReaderSettings() { + const { t } = useTranslation(); + const clientConfig = useClientConfig(); + const { + settings, + serverSettings, + localOverrides, + hasLocalOverrides, + clearServerDefaults, + clearLocalOverrides, + updateServerSetting, + } = useReaderSettings(); + + // Local state for collapsible + const [isOpen, setIsOpen] = useState(false); + + // Local state for slider dragging (null = not dragging, use server value) + const [draggingFontSize, setDraggingFontSize] = useState<number | null>(null); + const [draggingLineHeight, setDraggingLineHeight] = useState<number | null>( + null, + ); + + const hasServerSettings = + serverSettings.fontSize !== null || + serverSettings.lineHeight !== null || + serverSettings.fontFamily !== null; + + const handleClearDefaults = () => { + clearServerDefaults(); + toast({ description: t("settings.info.reader_settings.defaults_cleared") }); + }; + + const handleClearLocalOverrides = () => { + clearLocalOverrides(); + toast({ + description: t("settings.info.reader_settings.local_overrides_cleared"), + }); + }; + + // Format local override for display + const formatLocalOverride = ( + key: "fontSize" | "lineHeight" | "fontFamily", + ) => { + const value = localOverrides[key]; + if (value === undefined) return null; + if (key === "fontSize") return formatFontSize(value as number); + if (key === "lineHeight") return formatLineHeight(value as number); + if (key === "fontFamily") { + switch (value) { + case "serif": + return t("settings.info.reader_settings.serif"); + case "sans": + return t("settings.info.reader_settings.sans"); + case "mono": + return t("settings.info.reader_settings.mono"); + } + } + return String(value); + }; + + return ( + <Collapsible open={isOpen} onOpenChange={setIsOpen}> + <Card> + <CardHeader> + <CollapsibleTrigger className="flex w-full items-center justify-between [&[data-state=open]>svg]:rotate-180"> + <div className="flex flex-col items-start gap-1 text-left"> + <CardTitle className="flex items-center gap-2 text-xl"> + <BookOpen className="h-5 w-5" /> + {t("settings.info.reader_settings.title")} + </CardTitle> + <CardDescription> + {t("settings.info.reader_settings.description")} + </CardDescription> + </div> + <ChevronDown className="h-5 w-5 shrink-0 transition-transform duration-200" /> + </CollapsibleTrigger> + </CardHeader> + <CollapsibleContent> + <CardContent className="space-y-6"> + {/* Local Overrides Warning */} + {hasLocalOverrides && ( + <Alert> + <AlertTriangle className="h-4 w-4" /> + <AlertDescription className="flex flex-col gap-3"> + <div> + <p className="font-medium"> + {t("settings.info.reader_settings.local_overrides_title")} + </p> + <p className="mt-1 text-sm text-muted-foreground"> + {t( + "settings.info.reader_settings.local_overrides_description", + )} + </p> + <ul className="mt-2 text-sm text-muted-foreground"> + {localOverrides.fontFamily !== undefined && ( + <li> + {t("settings.info.reader_settings.font_family")}:{" "} + {formatLocalOverride("fontFamily")} + </li> + )} + {localOverrides.fontSize !== undefined && ( + <li> + {t("settings.info.reader_settings.font_size")}:{" "} + {formatLocalOverride("fontSize")} + </li> + )} + {localOverrides.lineHeight !== undefined && ( + <li> + {t("settings.info.reader_settings.line_height")}:{" "} + {formatLocalOverride("lineHeight")} + </li> + )} + </ul> + </div> + <Button + variant="outline" + size="sm" + onClick={handleClearLocalOverrides} + className="w-fit" + > + <Laptop className="mr-2 h-4 w-4" /> + {t("settings.info.reader_settings.clear_local_overrides")} + </Button> + </AlertDescription> + </Alert> + )} + + {/* Font Family */} + <div className="space-y-2"> + <Label className="text-sm font-medium"> + {t("settings.info.reader_settings.font_family")} + </Label> + <Select + disabled={!!clientConfig.demoMode} + value={serverSettings.fontFamily ?? "not-set"} + onValueChange={(value) => { + if (value !== "not-set") { + updateServerSetting({ + fontFamily: value as "serif" | "sans" | "mono", + }); + } + }} + > + <SelectTrigger className="h-11"> + <SelectValue + placeholder={t("settings.info.reader_settings.not_set")} + /> + </SelectTrigger> + <SelectContent> + <SelectItem value="not-set" disabled> + {t("settings.info.reader_settings.not_set")} ( + {t("common.default")}: {READER_DEFAULTS.fontFamily}) + </SelectItem> + <SelectItem value="serif"> + {t("settings.info.reader_settings.serif")} + </SelectItem> + <SelectItem value="sans"> + {t("settings.info.reader_settings.sans")} + </SelectItem> + <SelectItem value="mono"> + {t("settings.info.reader_settings.mono")} + </SelectItem> + </SelectContent> + </Select> + {serverSettings.fontFamily === null && ( + <p className="text-xs text-muted-foreground"> + {t("settings.info.reader_settings.using_default")}:{" "} + {READER_DEFAULTS.fontFamily} + </p> + )} + </div> + + {/* Font Size */} + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <Label className="text-sm font-medium"> + {t("settings.info.reader_settings.font_size")} + </Label> + <span className="text-sm text-muted-foreground"> + {formatFontSize(draggingFontSize ?? settings.fontSize)} + {serverSettings.fontSize === null && + draggingFontSize === null && + ` (${t("common.default").toLowerCase()})`} + </span> + </div> + <Slider + disabled={!!clientConfig.demoMode} + value={[draggingFontSize ?? settings.fontSize]} + onValueChange={([value]) => setDraggingFontSize(value)} + onValueCommit={([value]) => { + updateServerSetting({ fontSize: value }); + setDraggingFontSize(null); + }} + max={READER_SETTING_CONSTRAINTS.fontSize.max} + min={READER_SETTING_CONSTRAINTS.fontSize.min} + step={READER_SETTING_CONSTRAINTS.fontSize.step} + /> + </div> + + {/* Line Height */} + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <Label className="text-sm font-medium"> + {t("settings.info.reader_settings.line_height")} + </Label> + <span className="text-sm text-muted-foreground"> + {formatLineHeight(draggingLineHeight ?? settings.lineHeight)} + {serverSettings.lineHeight === null && + draggingLineHeight === null && + ` (${t("common.default").toLowerCase()})`} + </span> + </div> + <Slider + disabled={!!clientConfig.demoMode} + value={[draggingLineHeight ?? settings.lineHeight]} + onValueChange={([value]) => setDraggingLineHeight(value)} + onValueCommit={([value]) => { + updateServerSetting({ lineHeight: value }); + setDraggingLineHeight(null); + }} + max={READER_SETTING_CONSTRAINTS.lineHeight.max} + min={READER_SETTING_CONSTRAINTS.lineHeight.min} + step={READER_SETTING_CONSTRAINTS.lineHeight.step} + /> + </div> + + {/* Clear Defaults Button */} + {hasServerSettings && ( + <Button + variant="outline" + onClick={handleClearDefaults} + className="w-full" + disabled={!!clientConfig.demoMode} + > + <RotateCcw className="mr-2 h-4 w-4" /> + {t("settings.info.reader_settings.clear_defaults")} + </Button> + )} + + {/* Preview */} + <div className="rounded-lg border p-4"> + <p className="mb-2 text-sm font-medium text-muted-foreground"> + {t("settings.info.reader_settings.preview")} + </p> + <p + style={{ + fontFamily: READER_FONT_FAMILIES[settings.fontFamily], + fontSize: `${draggingFontSize ?? settings.fontSize}px`, + lineHeight: draggingLineHeight ?? settings.lineHeight, + }} + > + {t("settings.info.reader_settings.preview_text")} + <br /> + {t("settings.info.reader_settings.preview_text")} + <br /> + {t("settings.info.reader_settings.preview_text")} + </p> + </div> + </CardContent> + </CollapsibleContent> + </Card> + </Collapsible> + ); +} diff --git a/apps/web/components/settings/RegenerateApiKey.tsx b/apps/web/components/settings/RegenerateApiKey.tsx index 1c034026..943d21ef 100644 --- a/apps/web/components/settings/RegenerateApiKey.tsx +++ b/apps/web/components/settings/RegenerateApiKey.tsx @@ -14,11 +14,13 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useMutation } from "@tanstack/react-query"; import { RefreshCcw } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + import ApiKeySuccess from "./ApiKeySuccess"; export default function RegenerateApiKey({ @@ -28,25 +30,28 @@ export default function RegenerateApiKey({ id: string; name: string; }) { + const api = useTRPC(); const { t } = useTranslation(); const router = useRouter(); const [key, setKey] = useState<string | undefined>(undefined); const [dialogOpen, setDialogOpen] = useState<boolean>(false); - const mutator = api.apiKeys.regenerate.useMutation({ - onSuccess: (resp) => { - setKey(resp.key); - router.refresh(); - }, - onError: () => { - toast({ - description: t("common.something_went_wrong"), - variant: "destructive", - }); - setDialogOpen(false); - }, - }); + const mutator = useMutation( + api.apiKeys.regenerate.mutationOptions({ + onSuccess: (resp) => { + setKey(resp.key); + router.refresh(); + }, + onError: () => { + toast({ + description: t("common.something_went_wrong"), + variant: "destructive", + }); + setDialogOpen(false); + }, + }), + ); const handleRegenerate = () => { mutator.mutate({ id }); diff --git a/apps/web/components/settings/SubscriptionSettings.tsx b/apps/web/components/settings/SubscriptionSettings.tsx index 53f1caf4..48ab1258 100644 --- a/apps/web/components/settings/SubscriptionSettings.tsx +++ b/apps/web/components/settings/SubscriptionSettings.tsx @@ -1,10 +1,13 @@ "use client"; import { useEffect } from "react"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { CreditCard, Loader2 } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + import { Alert, AlertDescription } from "../ui/alert"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; @@ -16,27 +19,29 @@ import { CardTitle, } from "../ui/card"; import { Skeleton } from "../ui/skeleton"; -import { toast } from "../ui/use-toast"; export default function SubscriptionSettings() { + const api = useTRPC(); const { t } = useTranslation(); const { data: subscriptionStatus, refetch, isLoading: isQueryLoading, - } = api.subscriptions.getSubscriptionStatus.useQuery(); + } = useQuery(api.subscriptions.getSubscriptionStatus.queryOptions()); - const { data: subscriptionPrice } = - api.subscriptions.getSubscriptionPrice.useQuery(); + const { data: subscriptionPrice } = useQuery( + api.subscriptions.getSubscriptionPrice.queryOptions(), + ); - const { mutate: syncStripeState } = - api.subscriptions.syncWithStripe.useMutation({ + const { mutate: syncStripeState } = useMutation( + api.subscriptions.syncWithStripe.mutationOptions({ onSuccess: () => { refetch(); }, - }); - const createCheckoutSession = - api.subscriptions.createCheckoutSession.useMutation({ + }), + ); + const createCheckoutSession = useMutation( + api.subscriptions.createCheckoutSession.mutationOptions({ onSuccess: (resp) => { if (resp.url) { window.location.href = resp.url; @@ -48,9 +53,10 @@ export default function SubscriptionSettings() { variant: "destructive", }); }, - }); - const createPortalSession = api.subscriptions.createPortalSession.useMutation( - { + }), + ); + const createPortalSession = useMutation( + api.subscriptions.createPortalSession.mutationOptions({ onSuccess: (resp) => { if (resp.url) { window.location.href = resp.url; @@ -62,7 +68,7 @@ export default function SubscriptionSettings() { variant: "destructive", }); }, - }, + }), ); const isLoading = diff --git a/apps/web/components/settings/UserAvatar.tsx b/apps/web/components/settings/UserAvatar.tsx new file mode 100644 index 00000000..6baff7c2 --- /dev/null +++ b/apps/web/components/settings/UserAvatar.tsx @@ -0,0 +1,149 @@ +"use client"; + +import type { ChangeEvent } from "react"; +import { useRef } from "react"; +import { ActionButton } from "@/components/ui/action-button"; +import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; +import { toast } from "@/components/ui/sonner"; +import { UserAvatar as UserAvatarImage } from "@/components/ui/user-avatar"; +import useUpload from "@/lib/hooks/upload-file"; +import { useTranslation } from "@/lib/i18n/client"; +import { Image as ImageIcon, Upload, User, X } from "lucide-react"; + +import { + useUpdateUserAvatar, + useWhoAmI, +} from "@karakeep/shared-react/hooks/users"; + +import { Button } from "../ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; + +export default function UserAvatar() { + const { t } = useTranslation(); + const fileInputRef = useRef<HTMLInputElement>(null); + const whoami = useWhoAmI(); + const image = whoami.data?.image ?? null; + + const updateAvatar = useUpdateUserAvatar({ + onError: () => { + toast({ + description: t("common.something_went_wrong"), + variant: "destructive", + }); + }, + }); + + const upload = useUpload({ + onSuccess: async (resp) => { + try { + await updateAvatar.mutateAsync({ assetId: resp.assetId }); + toast({ + description: t("settings.info.avatar.updated"), + }); + } catch { + // handled in onError + } + }, + onError: (err) => { + toast({ + description: err.error, + variant: "destructive", + }); + }, + }); + + const isBusy = upload.isPending || updateAvatar.isPending; + + const handleSelectFile = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + upload.mutate(file); + event.target.value = ""; + }; + + return ( + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-xl"> + <ImageIcon className="h-5 w-5" /> + {t("settings.info.avatar.title")} + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <p className="text-sm text-muted-foreground"> + {t("settings.info.avatar.description")} + </p> + <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> + <div className="flex items-center gap-4"> + <div className="flex size-16 items-center justify-center overflow-hidden rounded-full border bg-muted"> + <UserAvatarImage + image={image} + name={t("settings.info.avatar.title")} + fallback={<User className="h-7 w-7 text-muted-foreground" />} + className="h-full w-full" + /> + </div> + <input + ref={fileInputRef} + type="file" + accept="image/*" + className="hidden" + onChange={handleFileChange} + /> + <ActionButton + type="button" + variant="secondary" + onClick={handleSelectFile} + loading={upload.isPending} + disabled={isBusy} + > + <Upload className="mr-2 h-4 w-4" /> + {image + ? t("settings.info.avatar.change") + : t("settings.info.avatar.upload")} + </ActionButton> + </div> + <ActionConfirmingDialog + title={t("settings.info.avatar.remove_confirm_title")} + description={ + <p>{t("settings.info.avatar.remove_confirm_description")}</p> + } + actionButton={(setDialogOpen) => ( + <ActionButton + type="button" + variant="destructive" + loading={updateAvatar.isPending} + onClick={() => + updateAvatar.mutate( + { assetId: null }, + { + onSuccess: () => { + toast({ + description: t("settings.info.avatar.removed"), + }); + setDialogOpen(false); + }, + }, + ) + } + > + {t("settings.info.avatar.remove")} + </ActionButton> + )} + > + <Button type="button" variant="outline" disabled={!image || isBusy}> + <X className="mr-2 h-4 w-4" /> + {t("settings.info.avatar.remove")} + </Button> + </ActionConfirmingDialog> + </div> + </CardContent> + </Card> + ); +} diff --git a/apps/web/components/settings/UserOptions.tsx b/apps/web/components/settings/UserOptions.tsx index 0df1085e..763695c5 100644 --- a/apps/web/components/settings/UserOptions.tsx +++ b/apps/web/components/settings/UserOptions.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { toast } from "@/components/ui/sonner"; import { useClientConfig } from "@/lib/clientConfig"; import { useTranslation } from "@/lib/i18n/client"; import { useInterfaceLang } from "@/lib/userLocalSettings/bookmarksLayout"; @@ -28,7 +29,6 @@ import { SelectTrigger, SelectValue, } from "../ui/select"; -import { toast } from "../ui/use-toast"; const LanguageSelect = () => { const lang = useInterfaceLang(); diff --git a/apps/web/components/settings/WebhookSettings.tsx b/apps/web/components/settings/WebhookSettings.tsx index 8efd3ba6..7a05b9e6 100644 --- a/apps/web/components/settings/WebhookSettings.tsx +++ b/apps/web/components/settings/WebhookSettings.tsx @@ -12,10 +12,10 @@ import { } from "@/components/ui/form"; import { FullPageSpinner } from "@/components/ui/full-page-spinner"; import { Input } from "@/components/ui/input"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Edit, KeyRound, @@ -28,6 +28,7 @@ import { import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { zNewWebhookSchema, zUpdateWebhookSchema, @@ -56,9 +57,10 @@ import { import { WebhookEventSelector } from "./WebhookEventSelector"; export function WebhooksEditorDialog() { + const api = useTRPC(); const { t } = useTranslation(); const [open, setOpen] = React.useState(false); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); const form = useForm<z.infer<typeof zNewWebhookSchema>>({ resolver: zodResolver(zNewWebhookSchema), @@ -75,16 +77,17 @@ export function WebhooksEditorDialog() { } }, [open]); - const { mutateAsync: createWebhook, isPending: isCreating } = - api.webhooks.create.useMutation({ + const { mutateAsync: createWebhook, isPending: isCreating } = useMutation( + api.webhooks.create.mutationOptions({ onSuccess: () => { toast({ description: "Webhook has been created!", }); - apiUtils.webhooks.list.invalidate(); + queryClient.invalidateQueries(api.webhooks.list.pathFilter()); setOpen(false); }, - }); + }), + ); return ( <Dialog open={open} onOpenChange={setOpen}> @@ -179,8 +182,9 @@ export function WebhooksEditorDialog() { } export function EditWebhookDialog({ webhook }: { webhook: ZWebhook }) { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); const [open, setOpen] = React.useState(false); React.useEffect(() => { if (open) { @@ -191,16 +195,17 @@ export function EditWebhookDialog({ webhook }: { webhook: ZWebhook }) { }); } }, [open]); - const { mutateAsync: updateWebhook, isPending: isUpdating } = - api.webhooks.update.useMutation({ + const { mutateAsync: updateWebhook, isPending: isUpdating } = useMutation( + api.webhooks.update.mutationOptions({ onSuccess: () => { toast({ description: "Webhook has been updated!", }); setOpen(false); - apiUtils.webhooks.list.invalidate(); + queryClient.invalidateQueries(api.webhooks.list.pathFilter()); }, - }); + }), + ); const updateSchema = zUpdateWebhookSchema.required({ events: true, url: true, @@ -302,8 +307,9 @@ export function EditWebhookDialog({ webhook }: { webhook: ZWebhook }) { } export function EditTokenDialog({ webhook }: { webhook: ZWebhook }) { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); const [open, setOpen] = React.useState(false); React.useEffect(() => { if (open) { @@ -331,16 +337,17 @@ export function EditTokenDialog({ webhook }: { webhook: ZWebhook }) { }, }); - const { mutateAsync: updateWebhook, isPending: isUpdating } = - api.webhooks.update.useMutation({ + const { mutateAsync: updateWebhook, isPending: isUpdating } = useMutation( + api.webhooks.update.mutationOptions({ onSuccess: () => { toast({ description: "Webhook token has been updated!", }); setOpen(false); - apiUtils.webhooks.list.invalidate(); + queryClient.invalidateQueries(api.webhooks.list.pathFilter()); }, - }); + }), + ); return ( <Dialog open={open} onOpenChange={setOpen}> @@ -432,17 +439,19 @@ export function EditTokenDialog({ webhook }: { webhook: ZWebhook }) { } export function WebhookRow({ webhook }: { webhook: ZWebhook }) { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); - const { mutate: deleteWebhook, isPending: isDeleting } = - api.webhooks.delete.useMutation({ + const queryClient = useQueryClient(); + const { mutate: deleteWebhook, isPending: isDeleting } = useMutation( + api.webhooks.delete.mutationOptions({ onSuccess: () => { toast({ description: "Webhook has been deleted!", }); - apiUtils.webhooks.list.invalidate(); + queryClient.invalidateQueries(api.webhooks.list.pathFilter()); }, - }); + }), + ); return ( <TableRow> @@ -479,8 +488,11 @@ export function WebhookRow({ webhook }: { webhook: ZWebhook }) { } export default function WebhookSettings() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: webhooks, isLoading } = api.webhooks.list.useQuery(); + const { data: webhooks, isLoading } = useQuery( + api.webhooks.list.queryOptions(), + ); return ( <div className="rounded-md border bg-background p-4"> <div className="flex flex-col gap-2"> diff --git a/apps/web/components/shared/sidebar/Sidebar.tsx b/apps/web/components/shared/sidebar/Sidebar.tsx index bf5a626b..3f4780e7 100644 --- a/apps/web/components/shared/sidebar/Sidebar.tsx +++ b/apps/web/components/shared/sidebar/Sidebar.tsx @@ -32,7 +32,10 @@ export default async function Sidebar({ </ul> </div> {extraSections} - <SidebarVersion serverVersion={serverConfig.serverVersion} /> + <SidebarVersion + serverVersion={serverConfig.serverVersion} + changeLogVersion={serverConfig.changelogVersion} + /> </aside> ); } diff --git a/apps/web/components/shared/sidebar/SidebarItem.tsx b/apps/web/components/shared/sidebar/SidebarItem.tsx index e602a435..eb61d48b 100644 --- a/apps/web/components/shared/sidebar/SidebarItem.tsx +++ b/apps/web/components/shared/sidebar/SidebarItem.tsx @@ -14,6 +14,11 @@ export default function SidebarItem({ style, collapseButton, right = null, + dropHighlight = false, + onDrop, + onDragOver, + onDragEnter, + onDragLeave, }: { name: string; logo: React.ReactNode; @@ -23,6 +28,11 @@ export default function SidebarItem({ linkClassName?: string; right?: React.ReactNode; collapseButton?: React.ReactNode; + dropHighlight?: boolean; + onDrop?: React.DragEventHandler; + onDragOver?: React.DragEventHandler; + onDragEnter?: React.DragEventHandler; + onDragLeave?: React.DragEventHandler; }) { const currentPath = usePathname(); return ( @@ -32,9 +42,14 @@ export default function SidebarItem({ path == currentPath ? "bg-accent/50 text-foreground" : "text-muted-foreground", + dropHighlight && "bg-accent ring-2 ring-primary", className, )} style={style} + onDrop={onDrop} + onDragOver={onDragOver} + onDragEnter={onDragEnter} + onDragLeave={onDragLeave} > <div className="flex-1"> {collapseButton} diff --git a/apps/web/components/shared/sidebar/SidebarLayout.tsx b/apps/web/components/shared/sidebar/SidebarLayout.tsx index 8ea8655e..e1b35634 100644 --- a/apps/web/components/shared/sidebar/SidebarLayout.tsx +++ b/apps/web/components/shared/sidebar/SidebarLayout.tsx @@ -1,7 +1,11 @@ +import { Suspense } from "react"; +import ErrorFallback from "@/components/dashboard/ErrorFallback"; import Header from "@/components/dashboard/header/Header"; import DemoModeBanner from "@/components/DemoModeBanner"; import { Separator } from "@/components/ui/separator"; +import LoadingSpinner from "@/components/ui/spinner"; import ValidAccountCheck from "@/components/utils/ValidAccountCheck"; +import { ErrorBoundary } from "react-error-boundary"; import serverConfig from "@karakeep/shared/config"; @@ -29,7 +33,11 @@ export default function SidebarLayout({ <Separator /> </div> {modal} - <div className="min-h-30 container p-4">{children}</div> + <div className="min-h-30 container p-4"> + <ErrorBoundary fallback={<ErrorFallback />}> + <Suspense fallback={<LoadingSpinner />}>{children}</Suspense> + </ErrorBoundary> + </div> </main> </div> </div> diff --git a/apps/web/components/shared/sidebar/SidebarVersion.tsx b/apps/web/components/shared/sidebar/SidebarVersion.tsx index fc2ec5a3..2d6d3380 100644 --- a/apps/web/components/shared/sidebar/SidebarVersion.tsx +++ b/apps/web/components/shared/sidebar/SidebarVersion.tsx @@ -46,36 +46,50 @@ function isStableRelease(version?: string) { } interface SidebarVersionProps { + // The actual version of the server serverVersion?: string; + // The version that should be displayed in the changelog + changeLogVersion?: string; } -export default function SidebarVersion({ serverVersion }: SidebarVersionProps) { +export default function SidebarVersion({ + serverVersion, + changeLogVersion, +}: SidebarVersionProps) { const { disableNewReleaseCheck } = useClientConfig(); const { t } = useTranslation(); - const stableRelease = isStableRelease(serverVersion); + const effectiveChangelogVersion = changeLogVersion ?? serverVersion; + const stableRelease = isStableRelease(effectiveChangelogVersion); const displayVersion = serverVersion ?? "unknown"; + const changelogDisplayVersion = effectiveChangelogVersion ?? displayVersion; const versionLabel = `Karakeep v${displayVersion}`; const releasePageUrl = useMemo(() => { - if (!serverVersion || !isStableRelease(serverVersion)) { + if ( + !effectiveChangelogVersion || + !isStableRelease(effectiveChangelogVersion) + ) { return GITHUB_REPO_URL; } - return `${GITHUB_RELEASE_URL}v${serverVersion}`; - }, [serverVersion]); + return `${GITHUB_RELEASE_URL}v${effectiveChangelogVersion}`; + }, [effectiveChangelogVersion]); const [open, setOpen] = useState(false); const [shouldNotify, setShouldNotify] = useState(false); const releaseNotesQuery = useQuery<string>({ - queryKey: ["sidebar-release-notes", serverVersion], + queryKey: ["sidebar-release-notes", effectiveChangelogVersion], queryFn: async ({ signal }) => { - if (!serverVersion) { + if (!effectiveChangelogVersion) { return ""; } - const response = await fetch(`${RELEASE_API_URL}v${serverVersion}`, { - signal, - }); + const response = await fetch( + `${RELEASE_API_URL}v${effectiveChangelogVersion}`, + { + signal, + }, + ); if (!response.ok) { throw new Error("Failed to load release notes"); @@ -89,7 +103,7 @@ export default function SidebarVersion({ serverVersion }: SidebarVersionProps) { open && stableRelease && !disableNewReleaseCheck && - Boolean(serverVersion), + Boolean(effectiveChangelogVersion), staleTime: RELEASE_NOTES_STALE_TIME, retry: 1, refetchOnWindowFocus: false, @@ -123,30 +137,34 @@ export default function SidebarVersion({ serverVersion }: SidebarVersionProps) { }, [releaseNotesQuery.error, t]); useEffect(() => { - if (!stableRelease || !serverVersion || disableNewReleaseCheck) { + if ( + !stableRelease || + !effectiveChangelogVersion || + disableNewReleaseCheck + ) { setShouldNotify(false); return; } try { const seenVersion = window.localStorage.getItem(LOCAL_STORAGE_KEY); - setShouldNotify(seenVersion !== serverVersion); + setShouldNotify(seenVersion !== effectiveChangelogVersion); } catch (error) { console.warn("Failed to read localStorage:", error); setShouldNotify(true); } - }, [serverVersion, stableRelease, disableNewReleaseCheck]); + }, [effectiveChangelogVersion, stableRelease, disableNewReleaseCheck]); const markReleaseAsSeen = useCallback(() => { - if (!serverVersion) return; + if (!effectiveChangelogVersion) return; try { - window.localStorage.setItem(LOCAL_STORAGE_KEY, serverVersion); + window.localStorage.setItem(LOCAL_STORAGE_KEY, effectiveChangelogVersion); } catch (error) { console.warn("Failed to write to localStorage:", error); // Ignore failures, we still clear the notification for the session } setShouldNotify(false); - }, [serverVersion]); + }, [effectiveChangelogVersion]); const handleOpenChange = useCallback( (nextOpen: boolean) => { @@ -202,7 +220,9 @@ export default function SidebarVersion({ serverVersion }: SidebarVersionProps) { <DialogContent className="max-w-3xl"> <DialogHeader> <DialogTitle> - {t("version.whats_new_title", { version: displayVersion })} + {t("version.whats_new_title", { + version: changelogDisplayVersion, + })} </DialogTitle> <DialogDescription> {t("version.release_notes_description")} diff --git a/apps/web/components/signin/CredentialsForm.tsx b/apps/web/components/signin/CredentialsForm.tsx index 4a4a0533..0ff5b1d0 100644 --- a/apps/web/components/signin/CredentialsForm.tsx +++ b/apps/web/components/signin/CredentialsForm.tsx @@ -14,10 +14,10 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { signIn } from "@/lib/auth/client"; import { useClientConfig } from "@/lib/clientConfig"; import { zodResolver } from "@hookform/resolvers/zod"; import { AlertCircle, Lock } from "lucide-react"; -import { signIn } from "next-auth/react"; import { useForm } from "react-hook-form"; import { z } from "zod"; diff --git a/apps/web/components/signin/ForgotPasswordForm.tsx b/apps/web/components/signin/ForgotPasswordForm.tsx index 29d55f2b..7ba37553 100644 --- a/apps/web/components/signin/ForgotPasswordForm.tsx +++ b/apps/web/components/signin/ForgotPasswordForm.tsx @@ -20,18 +20,21 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; import { TRPCClientError } from "@trpc/client"; import { AlertCircle, CheckCircle } from "lucide-react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + const forgotPasswordSchema = z.object({ email: z.string().email("Please enter a valid email address"), }); export default function ForgotPasswordForm() { + const api = useTRPC(); const [isSubmitted, setIsSubmitted] = useState(false); const [errorMessage, setErrorMessage] = useState(""); const router = useRouter(); @@ -40,7 +43,9 @@ export default function ForgotPasswordForm() { resolver: zodResolver(forgotPasswordSchema), }); - const forgotPasswordMutation = api.users.forgotPassword.useMutation(); + const forgotPasswordMutation = useMutation( + api.users.forgotPassword.mutationOptions(), + ); const onSubmit = async (values: z.infer<typeof forgotPasswordSchema>) => { try { diff --git a/apps/web/components/signin/ResetPasswordForm.tsx b/apps/web/components/signin/ResetPasswordForm.tsx index d4d8a285..571a09ae 100644 --- a/apps/web/components/signin/ResetPasswordForm.tsx +++ b/apps/web/components/signin/ResetPasswordForm.tsx @@ -20,13 +20,14 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; import { TRPCClientError } from "@trpc/client"; import { AlertCircle, CheckCircle } from "lucide-react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { zResetPasswordSchema } from "@karakeep/shared/types/users"; const resetPasswordSchema = z @@ -44,6 +45,7 @@ interface ResetPasswordFormProps { } export default function ResetPasswordForm({ token }: ResetPasswordFormProps) { + const api = useTRPC(); const [isSuccess, setIsSuccess] = useState(false); const [errorMessage, setErrorMessage] = useState(""); const router = useRouter(); @@ -52,7 +54,9 @@ export default function ResetPasswordForm({ token }: ResetPasswordFormProps) { resolver: zodResolver(resetPasswordSchema), }); - const resetPasswordMutation = api.users.resetPassword.useMutation(); + const resetPasswordMutation = useMutation( + api.users.resetPassword.mutationOptions(), + ); const onSubmit = async (values: z.infer<typeof resetPasswordSchema>) => { try { diff --git a/apps/web/components/signin/SignInProviderButton.tsx b/apps/web/components/signin/SignInProviderButton.tsx index edb411e6..4b218e2a 100644 --- a/apps/web/components/signin/SignInProviderButton.tsx +++ b/apps/web/components/signin/SignInProviderButton.tsx @@ -1,7 +1,7 @@ "use client"; import { Button } from "@/components/ui/button"; -import { signIn } from "next-auth/react"; +import { signIn } from "@/lib/auth/client"; export default function SignInProviderButton({ provider, diff --git a/apps/web/components/signup/SignUpForm.tsx b/apps/web/components/signup/SignUpForm.tsx index 340b461a..15b64fab 100644 --- a/apps/web/components/signup/SignUpForm.tsx +++ b/apps/web/components/signup/SignUpForm.tsx @@ -23,21 +23,28 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { signIn } from "@/lib/auth/client"; import { useClientConfig } from "@/lib/clientConfig"; -import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; import { Turnstile } from "@marsidev/react-turnstile"; +import { useMutation } from "@tanstack/react-query"; import { TRPCClientError } from "@trpc/client"; import { AlertCircle, UserX } from "lucide-react"; -import { signIn } from "next-auth/react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { zSignUpSchema } from "@karakeep/shared/types/users"; +import { isMobileAppRedirect } from "@karakeep/shared/utils/redirectUrl"; const VERIFY_EMAIL_ERROR = "Please verify your email address before signing in"; -export default function SignUpForm() { +interface SignUpFormProps { + redirectUrl: string; +} + +export default function SignUpForm({ redirectUrl }: SignUpFormProps) { + const api = useTRPC(); const form = useForm<z.infer<typeof zSignUpSchema>>({ resolver: zodResolver(zSignUpSchema), defaultValues: { @@ -54,7 +61,7 @@ export default function SignUpForm() { const turnstileSiteKey = clientConfig.turnstile?.siteKey; const turnstileRef = useRef<TurnstileInstance>(null); - const createUserMutation = api.users.create.useMutation(); + const createUserMutation = useMutation(api.users.create.mutationOptions()); if ( clientConfig.auth.disableSignups || @@ -111,7 +118,10 @@ export default function SignUpForm() { } form.clearErrors("turnstileToken"); try { - await createUserMutation.mutateAsync(value); + await createUserMutation.mutateAsync({ + ...value, + redirectUrl, + }); } catch (e) { if (e instanceof TRPCClientError) { setErrorMessage(e.message); @@ -131,7 +141,7 @@ export default function SignUpForm() { if (!resp || !resp.ok || resp.error) { if (resp?.error === VERIFY_EMAIL_ERROR) { router.replace( - `/check-email?email=${encodeURIComponent(value.email.trim())}`, + `/check-email?email=${encodeURIComponent(value.email.trim())}&redirectUrl=${encodeURIComponent(redirectUrl)}`, ); } else { setErrorMessage( @@ -145,7 +155,11 @@ export default function SignUpForm() { } return; } - router.replace("/"); + if (isMobileAppRedirect(redirectUrl)) { + window.location.href = redirectUrl; + } else { + router.replace(redirectUrl); + } })} className="space-y-4" > diff --git a/apps/web/components/subscription/QuotaProgress.tsx b/apps/web/components/subscription/QuotaProgress.tsx index 525eae8f..29bb7fc9 100644 --- a/apps/web/components/subscription/QuotaProgress.tsx +++ b/apps/web/components/subscription/QuotaProgress.tsx @@ -1,9 +1,11 @@ "use client"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; import { Database, HardDrive } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + import { Card, CardContent, @@ -110,9 +112,11 @@ function QuotaProgressItem({ } export function QuotaProgress() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: quotaUsage, isLoading } = - api.subscriptions.getQuotaUsage.useQuery(); + const { data: quotaUsage, isLoading } = useQuery( + api.subscriptions.getQuotaUsage.queryOptions(), + ); if (isLoading) { return ( diff --git a/apps/web/components/theme-provider.tsx b/apps/web/components/theme-provider.tsx index 1ab9a49d..1179bdfe 100644 --- a/apps/web/components/theme-provider.tsx +++ b/apps/web/components/theme-provider.tsx @@ -5,7 +5,11 @@ import * as React from "react"; import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes"; export function ThemeProvider({ children, ...props }: ThemeProviderProps) { - return <NextThemesProvider {...props}>{children}</NextThemesProvider>; + return ( + <NextThemesProvider scriptProps={{ "data-cfasync": "false" }} {...props}> + {children} + </NextThemesProvider> + ); } export function useToggleTheme() { diff --git a/apps/web/components/ui/avatar.tsx b/apps/web/components/ui/avatar.tsx new file mode 100644 index 00000000..48ec676b --- /dev/null +++ b/apps/web/components/ui/avatar.tsx @@ -0,0 +1,49 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@/lib/utils"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +const Avatar = React.forwardRef< + React.ElementRef<typeof AvatarPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> +>(({ className, ...props }, ref) => ( + <AvatarPrimitive.Root + ref={ref} + className={cn( + "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", + className, + )} + {...props} + /> +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef<typeof AvatarPrimitive.Image>, + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> +>(({ className, ...props }, ref) => ( + <AvatarPrimitive.Image + ref={ref} + className={cn("aspect-square h-full w-full", className)} + {...props} + /> +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef<typeof AvatarPrimitive.Fallback>, + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> +>(({ className, ...props }, ref) => ( + <AvatarPrimitive.Fallback + ref={ref} + className={cn( + "flex h-full w-full items-center justify-center rounded-full bg-black text-white", + className, + )} + {...props} + /> +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/apps/web/components/ui/copy-button.tsx b/apps/web/components/ui/copy-button.tsx index 8d8699f8..fb1f943f 100644 --- a/apps/web/components/ui/copy-button.tsx +++ b/apps/web/components/ui/copy-button.tsx @@ -1,9 +1,9 @@ import React, { useEffect, useState } from "react";
+import { toast } from "@/components/ui/sonner";
import { cn } from "@/lib/utils";
import { Check, Copy } from "lucide-react";
import { Button } from "./button";
-import { toast } from "./use-toast";
export default function CopyBtn({
className,
diff --git a/apps/web/components/ui/field.tsx b/apps/web/components/ui/field.tsx new file mode 100644 index 00000000..a52897f5 --- /dev/null +++ b/apps/web/components/ui/field.tsx @@ -0,0 +1,244 @@ +"use client"; + +import type { VariantProps } from "class-variance-authority"; +import { useMemo } from "react"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; +import { cva } from "class-variance-authority"; + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( + <fieldset + data-slot="field-set" + className={cn( + "flex flex-col gap-6", + "has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className, + )} + {...props} + /> + ); +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + <legend + data-slot="field-legend" + data-variant={variant} + className={cn( + "mb-3 font-medium", + "data-[variant=legend]:text-base", + "data-[variant=label]:text-sm", + className, + )} + {...props} + /> + ); +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="field-group" + className={cn( + "group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4", + className, + )} + {...props} + /> + ); +} + +const fieldVariants = cva( + "group/field flex w-full gap-3 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], + horizontal: [ + "flex-row items-center", + "[&>[data-slot=field-label]]:flex-auto", + "has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px has-[>[data-slot=field-content]]:items-start", + ], + responsive: [ + "@md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto flex-col [&>*]:w-full [&>.sr-only]:w-auto", + "@md/field-group:[&>[data-slot=field-label]]:flex-auto", + "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + }, + }, + defaultVariants: { + orientation: "vertical", + }, + }, +); + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) { + return ( + <div + role="group" + data-slot="field" + data-orientation={orientation} + className={cn(fieldVariants({ orientation }), className)} + {...props} + /> + ); +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="field-content" + className={cn( + "group/field-content flex flex-1 flex-col gap-1.5 leading-snug", + className, + )} + {...props} + /> + ); +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps<typeof Label>) { + return ( + <Label + data-slot="field-label" + className={cn( + "group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50", + "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>[data-slot=field]]:p-4", + "has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10", + className, + )} + {...props} + /> + ); +} + +function FieldTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="field-label" + className={cn( + "flex w-fit items-center gap-2 text-sm font-medium leading-snug group-data-[disabled=true]/field:opacity-50", + className, + )} + {...props} + /> + ); +} + +function FieldDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( + <p + data-slot="field-description" + className={cn( + "text-sm font-normal leading-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance", + "nth-last-2:-mt-1 last:mt-0 [[data-variant=legend]+&]:-mt-1.5", + "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", + className, + )} + {...props} + /> + ); +} + +function FieldSeparator({ + children, + className, + ...props +}: React.ComponentProps<"div"> & { + children?: React.ReactNode; +}) { + return ( + <div + data-slot="field-separator" + data-content={!!children} + className={cn( + "relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2", + className, + )} + {...props} + > + <Separator className="absolute inset-0 top-1/2" /> + {children && ( + <span + className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground" + data-slot="field-separator-content" + > + {children} + </span> + )} + </div> + ); +} + +function FieldError({ + className, + children, + errors, + ...props +}: React.ComponentProps<"div"> & { + errors?: ({ message?: string } | undefined)[]; +}) { + const content = useMemo(() => { + if (children) { + return children; + } + + if (!errors) { + return null; + } + + if (errors?.length === 1 && errors[0]?.message) { + return errors[0].message; + } + + return ( + <ul className="ml-4 flex list-disc flex-col gap-1"> + {errors.map( + (error, index) => + error?.message && <li key={index}>{error.message}</li>, + )} + </ul> + ); + }, [children, errors]); + + if (!content) { + return null; + } + + return ( + <div + role="alert" + data-slot="field-error" + className={cn("text-sm font-normal text-destructive", className)} + {...props} + > + {content} + </div> + ); +} + +export { + Field, + FieldLabel, + FieldDescription, + FieldError, + FieldGroup, + FieldLegend, + FieldSeparator, + FieldSet, + FieldContent, + FieldTitle, +}; diff --git a/apps/web/components/ui/info-tooltip.tsx b/apps/web/components/ui/info-tooltip.tsx index 4dd97199..9d525983 100644 --- a/apps/web/components/ui/info-tooltip.tsx +++ b/apps/web/components/ui/info-tooltip.tsx @@ -22,8 +22,7 @@ export default function InfoTooltip({ <TooltipTrigger asChild> {variant === "tip" ? ( <Info - color="#494949" - className={cn("z-10 cursor-pointer", className)} + className={cn("z-10 cursor-pointer text-[#494949]", className)} size={size} /> ) : ( diff --git a/apps/web/components/ui/radio-group.tsx b/apps/web/components/ui/radio-group.tsx new file mode 100644 index 00000000..0da1136e --- /dev/null +++ b/apps/web/components/ui/radio-group.tsx @@ -0,0 +1,43 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@/lib/utils"; +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; +import { Circle } from "lucide-react"; + +const RadioGroup = React.forwardRef< + React.ElementRef<typeof RadioGroupPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root> +>(({ className, ...props }, ref) => { + return ( + <RadioGroupPrimitive.Root + className={cn("grid gap-2", className)} + {...props} + ref={ref} + /> + ); +}); +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; + +const RadioGroupItem = React.forwardRef< + React.ElementRef<typeof RadioGroupPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> +>(({ className, ...props }, ref) => { + return ( + <RadioGroupPrimitive.Item + ref={ref} + className={cn( + "aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", + className, + )} + {...props} + > + <RadioGroupPrimitive.Indicator className="flex items-center justify-center"> + <Circle className="h-2.5 w-2.5 fill-current text-current" /> + </RadioGroupPrimitive.Indicator> + </RadioGroupPrimitive.Item> + ); +}); +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; + +export { RadioGroup, RadioGroupItem }; diff --git a/apps/web/components/ui/sonner.tsx b/apps/web/components/ui/sonner.tsx new file mode 100644 index 00000000..d281f4ae --- /dev/null +++ b/apps/web/components/ui/sonner.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { + CircleCheck, + Info, + LoaderCircle, + OctagonX, + TriangleAlert, +} from "lucide-react"; +import { useTheme } from "next-themes"; +import { Toaster as Sonner, toast } from "sonner"; + +type ToasterProps = React.ComponentProps<typeof Sonner>; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme(); + + return ( + <Sonner + theme={theme as ToasterProps["theme"]} + className="toaster group" + icons={{ + success: <CircleCheck className="h-4 w-4" />, + info: <Info className="h-4 w-4" />, + warning: <TriangleAlert className="h-4 w-4" />, + error: <OctagonX className="h-4 w-4" />, + loading: <LoaderCircle className="h-4 w-4 animate-spin" />, + }} + toastOptions={{ + classNames: { + toast: + "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", + description: "group-[.toast]:text-muted-foreground", + actionButton: + "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", + cancelButton: + "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", + }, + }} + {...props} + /> + ); +}; + +/** + * Compat layer for migrating from old toaster to sonner + * @deprecated Use sonner's natie toast instead + */ +const legacyToast = ({ + title, + description, + variant, +}: { + title?: React.ReactNode; + description?: React.ReactNode; + variant?: "destructive" | "default"; +}) => { + let toastTitle = title; + let toastDescription: React.ReactNode | undefined = description; + if (!title) { + toastTitle = description; + toastDescription = undefined; + } + if (variant === "destructive") { + toast.error(toastTitle, { description: toastDescription }); + } else { + toast(toastTitle, { description: toastDescription }); + } +}; + +export { Toaster, legacyToast as toast }; diff --git a/apps/web/components/ui/toaster.tsx b/apps/web/components/ui/toaster.tsx deleted file mode 100644 index 7d82ed55..00000000 --- a/apps/web/components/ui/toaster.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -import { - Toast, - ToastClose, - ToastDescription, - ToastProvider, - ToastTitle, - ToastViewport, -} from "@/components/ui/toast"; -import { useToast } from "@/components/ui/use-toast"; - -export function Toaster() { - const { toasts } = useToast(); - - return ( - <ToastProvider> - {toasts.map(function ({ id, title, description, action, ...props }) { - return ( - <Toast key={id} {...props}> - <div className="grid gap-1"> - {title && <ToastTitle>{title}</ToastTitle>} - {description && ( - <ToastDescription>{description}</ToastDescription> - )} - </div> - {action} - <ToastClose /> - </Toast> - ); - })} - <ToastViewport /> - </ToastProvider> - ); -} diff --git a/apps/web/components/ui/use-toast.ts b/apps/web/components/ui/use-toast.ts deleted file mode 100644 index c3e7e884..00000000 --- a/apps/web/components/ui/use-toast.ts +++ /dev/null @@ -1,188 +0,0 @@ -// Inspired by react-hot-toast library -import type { ToastActionElement, ToastProps } from "@/components/ui/toast"; -import * as React from "react"; - -const TOAST_LIMIT = 10; -const TOAST_REMOVE_DELAY = 1000000; - -type ToasterToast = ToastProps & { - id: string; - title?: React.ReactNode; - description?: React.ReactNode; - action?: ToastActionElement; -}; - -const actionTypes = { - ADD_TOAST: "ADD_TOAST", - UPDATE_TOAST: "UPDATE_TOAST", - DISMISS_TOAST: "DISMISS_TOAST", - REMOVE_TOAST: "REMOVE_TOAST", -} as const; - -let count = 0; - -function genId() { - count = (count + 1) % Number.MAX_SAFE_INTEGER; - return count.toString(); -} - -type ActionType = typeof actionTypes; - -type Action = - | { - type: ActionType["ADD_TOAST"]; - toast: ToasterToast; - } - | { - type: ActionType["UPDATE_TOAST"]; - toast: Partial<ToasterToast>; - } - | { - type: ActionType["DISMISS_TOAST"]; - toastId?: ToasterToast["id"]; - } - | { - type: ActionType["REMOVE_TOAST"]; - toastId?: ToasterToast["id"]; - }; - -interface State { - toasts: ToasterToast[]; -} - -const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>(); - -const addToRemoveQueue = (toastId: string) => { - if (toastTimeouts.has(toastId)) { - return; - } - - const timeout = setTimeout(() => { - toastTimeouts.delete(toastId); - dispatch({ - type: "REMOVE_TOAST", - toastId: toastId, - }); - }, TOAST_REMOVE_DELAY); - - toastTimeouts.set(toastId, timeout); -}; - -export const reducer = (state: State, action: Action): State => { - switch (action.type) { - case "ADD_TOAST": - return { - ...state, - toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), - }; - - case "UPDATE_TOAST": - return { - ...state, - toasts: state.toasts.map((t) => - t.id === action.toast.id ? { ...t, ...action.toast } : t, - ), - }; - - case "DISMISS_TOAST": { - const { toastId } = action; - - // ! Side effects ! - This could be extracted into a dismissToast() action, - // but I'll keep it here for simplicity - if (toastId) { - addToRemoveQueue(toastId); - } else { - state.toasts.forEach((toast) => { - addToRemoveQueue(toast.id); - }); - } - - return { - ...state, - toasts: state.toasts.map((t) => - t.id === toastId || toastId === undefined - ? { - ...t, - open: false, - } - : t, - ), - }; - } - case "REMOVE_TOAST": - if (action.toastId === undefined) { - return { - ...state, - toasts: [], - }; - } - return { - ...state, - toasts: state.toasts.filter((t) => t.id !== action.toastId), - }; - } -}; - -const listeners: ((_state: State) => void)[] = []; - -let memoryState: State = { toasts: [] }; - -function dispatch(action: Action) { - memoryState = reducer(memoryState, action); - listeners.forEach((listener) => { - listener(memoryState); - }); -} - -type Toast = Omit<ToasterToast, "id">; - -function toast({ ...props }: Toast) { - const id = genId(); - - const update = (props: ToasterToast) => - dispatch({ - type: "UPDATE_TOAST", - toast: { ...props, id }, - }); - const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); - - dispatch({ - type: "ADD_TOAST", - toast: { - ...props, - id, - open: true, - onOpenChange: (open) => { - if (!open) dismiss(); - }, - }, - }); - - return { - id: id, - dismiss, - update, - }; -} - -function useToast() { - const [state, setState] = React.useState<State>(memoryState); - - React.useEffect(() => { - listeners.push(setState); - return () => { - const index = listeners.indexOf(setState); - if (index > -1) { - listeners.splice(index, 1); - } - }; - }, [state]); - - return { - ...state, - toast, - dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), - }; -} - -export { useToast, toast }; diff --git a/apps/web/components/ui/user-avatar.tsx b/apps/web/components/ui/user-avatar.tsx new file mode 100644 index 00000000..4ebb6ec3 --- /dev/null +++ b/apps/web/components/ui/user-avatar.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useMemo } from "react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { cn } from "@/lib/utils"; + +import { getAssetUrl } from "@karakeep/shared/utils/assetUtils"; + +interface UserAvatarProps { + image?: string | null; + name?: string | null; + className?: string; + imgClassName?: string; + fallbackClassName?: string; + fallback?: React.ReactNode; +} + +const isExternalUrl = (value: string) => + value.startsWith("http://") || value.startsWith("https://"); + +export function UserAvatar({ + image, + name, + className, + imgClassName, + fallbackClassName, + fallback, +}: UserAvatarProps) { + const avatarUrl = useMemo(() => { + if (!image) { + return null; + } + return isExternalUrl(image) ? image : getAssetUrl(image); + }, [image]); + + const fallbackContent = fallback ?? name?.charAt(0) ?? "U"; + + return ( + <Avatar className={className}> + {avatarUrl && ( + <AvatarImage + src={avatarUrl} + alt={name ?? "User"} + className={cn("object-cover", imgClassName)} + /> + )} + <AvatarFallback className={cn("text-sm font-medium", fallbackClassName)}> + {fallbackContent} + </AvatarFallback> + </Avatar> + ); +} diff --git a/apps/web/components/utils/ValidAccountCheck.tsx b/apps/web/components/utils/ValidAccountCheck.tsx index 5ca5fd5c..54d27b34 100644 --- a/apps/web/components/utils/ValidAccountCheck.tsx +++ b/apps/web/components/utils/ValidAccountCheck.tsx @@ -2,22 +2,27 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; /** * This component is used to address a confusion when the JWT token exists but the user no longer exists in the database. * So this component synchronusly checks if the user is still valid and if not, signs out the user. */ export default function ValidAccountCheck() { + const api = useTRPC(); const router = useRouter(); - const { error } = api.users.whoami.useQuery(undefined, { - retry: (_failureCount, error) => { - if (error.data?.code === "UNAUTHORIZED") { - return false; - } - return true; - }, - }); + const { error } = useQuery( + api.users.whoami.queryOptions(undefined, { + retry: (_failureCount, error) => { + if (error.data?.code === "UNAUTHORIZED") { + return false; + } + return true; + }, + }), + ); useEffect(() => { if (error?.data?.code === "UNAUTHORIZED") { router.push("/logout"); diff --git a/apps/web/components/wrapped/ShareButton.tsx b/apps/web/components/wrapped/ShareButton.tsx new file mode 100644 index 00000000..048cafea --- /dev/null +++ b/apps/web/components/wrapped/ShareButton.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { RefObject, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Download, Loader2, Share2 } from "lucide-react"; +import { domToPng } from "modern-screenshot"; + +interface ShareButtonProps { + contentRef: RefObject<HTMLDivElement | null>; + fileName?: string; +} + +export function ShareButton({ + contentRef, + fileName = "karakeep-wrapped-2025.png", +}: ShareButtonProps) { + const [isGenerating, setIsGenerating] = useState(false); + + const handleShare = async () => { + if (!contentRef.current) return; + + setIsGenerating(true); + + try { + // Capture the content as PNG data URL + const dataUrl = await domToPng(contentRef.current, { + scale: 2, // Higher resolution + quality: 1, + debug: false, + width: contentRef.current.scrollWidth, // Capture full width + height: contentRef.current.scrollHeight, // Capture full height including scrolled content + drawImageInterval: 100, // Add delay for rendering + }); + + // Convert data URL to blob + const response = await fetch(dataUrl); + const blob = await response.blob(); + + // Try native share API first (works well on mobile) + if ( + typeof navigator.share !== "undefined" && + typeof navigator.canShare !== "undefined" + ) { + const file = new File([blob], fileName, { type: "image/png" }); + if (navigator.canShare({ files: [file] })) { + await navigator.share({ + files: [file], + title: "My 2025 Karakeep Wrapped", + text: "Check out my 2025 Karakeep Wrapped!", + }); + return; + } + } + + // Fallback: download the image + const a = document.createElement("a"); + a.href = dataUrl; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } catch (error) { + console.error("Failed to capture or share image:", error); + } finally { + setIsGenerating(false); + } + }; + + const isNativeShareAvailable = + typeof navigator.share !== "undefined" && + typeof navigator.canShare !== "undefined"; + + return ( + <Button + onClick={handleShare} + disabled={isGenerating} + size="icon" + variant="ghost" + className="h-10 w-10 rounded-full bg-white/10 text-slate-100 hover:bg-white/20" + aria-label={isNativeShareAvailable ? "Share" : "Download"} + title={isNativeShareAvailable ? "Share" : "Download"} + > + {isGenerating ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : isNativeShareAvailable ? ( + <Share2 className="h-4 w-4" /> + ) : ( + <Download className="h-4 w-4" /> + )} + </Button> + ); +} diff --git a/apps/web/components/wrapped/WrappedContent.tsx b/apps/web/components/wrapped/WrappedContent.tsx new file mode 100644 index 00000000..261aadfd --- /dev/null +++ b/apps/web/components/wrapped/WrappedContent.tsx @@ -0,0 +1,390 @@ +"use client"; + +import { forwardRef } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { + BookOpen, + Calendar, + Chrome, + Clock, + Code, + FileText, + Globe, + Hash, + Heart, + Highlighter, + Link, + Rss, + Smartphone, + Upload, + Zap, +} from "lucide-react"; +import { z } from "zod"; + +import { zBookmarkSourceSchema } from "@karakeep/shared/types/bookmarks"; +import { zWrappedStatsResponseSchema } from "@karakeep/shared/types/users"; + +type WrappedStats = z.infer<typeof zWrappedStatsResponseSchema>; +type BookmarkSource = z.infer<typeof zBookmarkSourceSchema>; + +interface WrappedContentProps { + stats: WrappedStats; + userName?: string; +} + +const dayNames = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +]; +const monthNames = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +function formatSourceName(source: BookmarkSource | null): string { + if (!source) return "Unknown"; + const sourceMap: Record<BookmarkSource, string> = { + api: "API", + web: "Web", + extension: "Browser Extension", + cli: "CLI", + mobile: "Mobile App", + singlefile: "SingleFile", + rss: "RSS Feed", + import: "Import", + }; + return sourceMap[source]; +} + +function getSourceIcon(source: BookmarkSource | null, className = "h-5 w-5") { + const iconProps = { className }; + switch (source) { + case "api": + return <Zap {...iconProps} />; + case "web": + return <Globe {...iconProps} />; + case "extension": + return <Chrome {...iconProps} />; + case "cli": + return <Code {...iconProps} />; + case "mobile": + return <Smartphone {...iconProps} />; + case "singlefile": + return <FileText {...iconProps} />; + case "rss": + return <Rss {...iconProps} />; + case "import": + return <Upload {...iconProps} />; + default: + return <Globe {...iconProps} />; + } +} + +export const WrappedContent = forwardRef<HTMLDivElement, WrappedContentProps>( + ({ stats, userName }, ref) => { + const maxMonthlyCount = Math.max( + ...stats.monthlyActivity.map((m) => m.count), + ); + + return ( + <div + ref={ref} + className="min-h-screen w-full overflow-auto bg-slate-950 bg-[radial-gradient(1200px_600px_at_20%_-10%,rgba(16,185,129,0.18),transparent),radial-gradient(900px_500px_at_90%_10%,rgba(14,116,144,0.2),transparent)] p-6 text-slate-100 md:p-8" + > + <div className="mx-auto max-w-5xl space-y-4"> + {/* Header */} + <div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between"> + <div> + <h1 className="text-2xl font-semibold md:text-3xl"> + Your {stats.year} Wrapped + </h1> + <p className="mt-1 text-xs text-slate-300 md:text-sm"> + A Year in Karakeep + </p> + {userName && ( + <p className="mt-2 text-sm text-slate-400">{userName}</p> + )} + </div> + </div> + + <div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3"> + <Card className="flex flex-col items-center justify-center border border-white/10 bg-white/5 p-4 text-center text-slate-100 backdrop-blur-sm"> + <p className="text-xs text-slate-300">You saved</p> + <p className="my-2 text-3xl font-semibold md:text-4xl"> + {stats.totalBookmarks} + </p> + <p className="text-xs text-slate-300"> + {stats.totalBookmarks === 1 ? "item" : "items"} this year + </p> + </Card> + {/* First Bookmark */} + {stats.firstBookmark && ( + <Card className="border border-white/10 bg-white/5 p-4 text-slate-100 backdrop-blur-sm"> + <div className="flex h-full flex-col"> + <div className="mb-3 flex items-center gap-2"> + <Calendar className="h-4 w-4 flex-shrink-0 text-emerald-300" /> + <p className="text-[10px] uppercase tracking-wide text-slate-400"> + First Bookmark of {stats.year} + </p> + </div> + <div className="flex-1"> + <p className="text-2xl font-bold text-slate-100"> + {new Date( + stats.firstBookmark.createdAt, + ).toLocaleDateString("en-US", { + month: "long", + day: "numeric", + })} + </p> + {stats.firstBookmark.title && ( + <p className="mt-2 line-clamp-2 text-base leading-relaxed text-slate-300"> + “{stats.firstBookmark.title}” + </p> + )} + </div> + </div> + </Card> + )} + + {/* Activity + Peak */} + <Card className="border border-white/10 bg-white/5 p-4 text-slate-100 backdrop-blur-sm"> + <h2 className="mb-2 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-300"> + <Clock className="h-4 w-4" /> + Activity Highlights + </h2> + <div className="grid gap-2 text-sm"> + {stats.mostActiveDay && ( + <div> + <p className="text-xs text-slate-400">Most Active Day</p> + <p className="text-base font-semibold"> + {new Date(stats.mostActiveDay.date).toLocaleDateString( + "en-US", + { + month: "short", + day: "numeric", + }, + )} + </p> + <p className="text-xs text-slate-400"> + {stats.mostActiveDay.count}{" "} + {stats.mostActiveDay.count === 1 ? "save" : "saves"} + </p> + </div> + )} + <div className="grid grid-cols-2 gap-2"> + <div> + <p className="text-xs text-slate-400">Peak Hour</p> + <p className="text-base font-semibold"> + {stats.peakHour === 0 + ? "12 AM" + : stats.peakHour < 12 + ? `${stats.peakHour} AM` + : stats.peakHour === 12 + ? "12 PM" + : `${stats.peakHour - 12} PM`} + </p> + </div> + <div> + <p className="text-xs text-slate-400">Peak Day</p> + <p className="text-base font-semibold"> + {dayNames[stats.peakDayOfWeek]} + </p> + </div> + </div> + </div> + </Card> + + {/* Top Lists */} + {(stats.topDomains.length > 0 || stats.topTags.length > 0) && ( + <Card className="border border-white/10 bg-white/5 p-4 text-slate-100 backdrop-blur-sm md:col-span-2 lg:col-span-2"> + <h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-slate-300"> + Top Lists + </h2> + <div className="grid gap-3 md:grid-cols-2"> + {stats.topDomains.length > 0 && ( + <div> + <h3 className="mb-1 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-slate-400"> + <Globe className="h-3.5 w-3.5" /> + Sites + </h3> + <div className="space-y-1.5 text-sm"> + {stats.topDomains.map((domain, index) => ( + <div + key={domain.domain} + className="flex items-center justify-between" + > + <div className="flex items-center gap-2"> + <div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-white/10 text-[10px] font-semibold text-slate-200"> + {index + 1} + </div> + <span className="text-slate-100"> + {domain.domain} + </span> + </div> + <Badge className="bg-white/10 text-[10px] text-slate-200"> + {domain.count} + </Badge> + </div> + ))} + </div> + </div> + )} + {stats.topTags.length > 0 && ( + <div> + <h3 className="mb-1 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-slate-400"> + <Hash className="h-3.5 w-3.5" /> + Tags + </h3> + <div className="space-y-1.5 text-sm"> + {stats.topTags.map((tag, index) => ( + <div + key={tag.name} + className="flex items-center justify-between" + > + <div className="flex items-center gap-2"> + <div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-white/10 text-[10px] font-semibold text-slate-200"> + {index + 1} + </div> + <span className="text-slate-100">{tag.name}</span> + </div> + <Badge className="bg-white/10 text-[10px] text-slate-200"> + {tag.count} + </Badge> + </div> + ))} + </div> + </div> + )} + </div> + </Card> + )} + + {/* Bookmarks by Source */} + {stats.bookmarksBySource.length > 0 && ( + <Card className="border border-white/10 bg-white/5 p-4 text-slate-100 backdrop-blur-sm"> + <h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-300"> + How You Save + </h2> + <div className="space-y-1.5 text-sm"> + {stats.bookmarksBySource.map((source) => ( + <div + key={source.source || "unknown"} + className="flex items-center justify-between" + > + <div className="flex items-center gap-2 text-slate-100"> + {getSourceIcon(source.source, "h-4 w-4")} + <span>{formatSourceName(source.source)}</span> + </div> + <Badge className="bg-white/10 text-[10px] text-slate-200"> + {source.count} + </Badge> + </div> + ))} + </div> + </Card> + )} + + {/* Monthly Activity */} + <Card className="border border-white/10 bg-white/5 p-4 text-slate-100 backdrop-blur-sm md:col-span-2 lg:col-span-3"> + <h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-300"> + <Calendar className="h-4 w-4" /> + Your Year in Saves + </h2> + <div className="grid gap-2 text-xs md:grid-cols-2 lg:grid-cols-3"> + {stats.monthlyActivity.map((month) => ( + <div key={month.month} className="flex items-center gap-2"> + <div className="w-7 text-right text-[10px] text-slate-400"> + {monthNames[month.month - 1]} + </div> + <div className="relative h-2 flex-1 overflow-hidden rounded-full bg-white/10"> + <div + className="h-full rounded-full bg-emerald-300/70" + style={{ + width: `${maxMonthlyCount > 0 ? (month.count / maxMonthlyCount) * 100 : 0}%`, + }} + /> + </div> + <div className="w-7 text-[10px] text-slate-300"> + {month.count} + </div> + </div> + ))} + </div> + </Card> + + {/* Summary Stats */} + <Card className="border border-white/10 bg-white/5 p-4 text-slate-100 backdrop-blur-sm md:col-span-2 lg:col-span-3"> + <div className="grid gap-3 text-center sm:grid-cols-3"> + <div className="rounded-lg bg-white/5 p-3"> + <Heart className="mx-auto mb-1 h-4 w-4 text-rose-200" /> + <p className="text-lg font-semibold"> + {stats.totalFavorites} + </p> + <p className="text-[10px] text-slate-400">Favorites</p> + </div> + <div className="rounded-lg bg-white/5 p-3"> + <Hash className="mx-auto mb-1 h-4 w-4 text-amber-200" /> + <p className="text-lg font-semibold">{stats.totalTags}</p> + <p className="text-[10px] text-slate-400">Tags Created</p> + </div> + <div className="rounded-lg bg-white/5 p-3"> + <Highlighter className="mx-auto mb-1 h-4 w-4 text-emerald-200" /> + <p className="text-lg font-semibold"> + {stats.totalHighlights} + </p> + <p className="text-[10px] text-slate-400">Highlights</p> + </div> + </div> + <div className="mt-3 grid gap-3 text-center sm:grid-cols-3"> + <div className="rounded-lg bg-white/5 p-3"> + <Link className="mx-auto mb-1 h-4 w-4 text-slate-200" /> + <p className="text-lg font-semibold"> + {stats.bookmarksByType.link} + </p> + <p className="text-[10px] text-slate-400">Links</p> + </div> + <div className="rounded-lg bg-white/5 p-3"> + <FileText className="mx-auto mb-1 h-4 w-4 text-slate-200" /> + <p className="text-lg font-semibold"> + {stats.bookmarksByType.text} + </p> + <p className="text-[10px] text-slate-400">Notes</p> + </div> + <div className="rounded-lg bg-white/5 p-3"> + <BookOpen className="mx-auto mb-1 h-4 w-4 text-slate-200" /> + <p className="text-lg font-semibold"> + {stats.bookmarksByType.asset} + </p> + <p className="text-[10px] text-slate-400">Assets</p> + </div> + </div> + </Card> + </div> + + {/* Footer */} + <div className="pb-4 pt-1 text-center text-[10px] text-slate-500"> + Made with Karakeep + </div> + </div> + </div> + ); + }, +); + +WrappedContent.displayName = "WrappedContent"; diff --git a/apps/web/components/wrapped/WrappedModal.tsx b/apps/web/components/wrapped/WrappedModal.tsx new file mode 100644 index 00000000..b8bf3e25 --- /dev/null +++ b/apps/web/components/wrapped/WrappedModal.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useRef } from "react"; +import { + Dialog, + DialogContent, + DialogOverlay, + DialogTitle, +} from "@/components/ui/dialog"; +import * as VisuallyHidden from "@radix-ui/react-visually-hidden"; +import { useQuery } from "@tanstack/react-query"; +import { Loader2, X } from "lucide-react"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; + +import { ShareButton } from "./ShareButton"; +import { WrappedContent } from "./WrappedContent"; + +interface WrappedModalProps { + open: boolean; + onClose: () => void; +} + +export function WrappedModal({ open, onClose }: WrappedModalProps) { + const api = useTRPC(); + const contentRef = useRef<HTMLDivElement | null>(null); + const { data: stats, isLoading } = useQuery( + api.users.wrapped.queryOptions(undefined, { + enabled: open, + }), + ); + const { data: whoami } = useQuery( + api.users.whoami.queryOptions(undefined, { + enabled: open, + }), + ); + + return ( + <Dialog open={open} onOpenChange={onClose}> + <DialogOverlay className="z-50" /> + <DialogContent + className="max-w-screen h-screen max-h-screen w-screen overflow-hidden rounded-none border-none p-0" + hideCloseBtn={true} + > + <VisuallyHidden.Root> + <DialogTitle>Your 2025 Wrapped</DialogTitle> + </VisuallyHidden.Root> + <div className="fixed right-4 top-4 z-50 flex items-center gap-2"> + {/* Share button overlay */} + {stats && !isLoading && <ShareButton contentRef={contentRef} />} + {/* Close button overlay */} + <button + onClick={onClose} + className="rounded-full bg-white/10 p-2 backdrop-blur-sm transition-colors hover:bg-white/20" + aria-label="Close" + title="Close" + > + <X className="h-5 w-5 text-white" /> + </button> + </div> + + {/* Content */} + {isLoading ? ( + <div className="flex h-full items-center justify-center bg-slate-950 bg-[radial-gradient(1200px_600px_at_20%_-10%,rgba(16,185,129,0.18),transparent),radial-gradient(900px_500px_at_90%_10%,rgba(14,116,144,0.2),transparent)]"> + <div className="text-center text-white"> + <Loader2 className="mx-auto mb-4 h-12 w-12 animate-spin" /> + <p className="text-xl">Loading your Wrapped...</p> + </div> + </div> + ) : stats ? ( + <WrappedContent + ref={contentRef} + stats={stats} + userName={whoami?.name || undefined} + /> + ) : ( + <div className="flex h-full items-center justify-center bg-slate-950 bg-[radial-gradient(1200px_600px_at_20%_-10%,rgba(16,185,129,0.18),transparent),radial-gradient(900px_500px_at_90%_10%,rgba(14,116,144,0.2),transparent)]"> + <div className="text-center text-white"> + <p className="text-xl">Failed to load your Wrapped stats</p> + <button + onClick={onClose} + className="mt-4 rounded-lg bg-white/20 px-6 py-2 backdrop-blur-sm hover:bg-white/30" + > + Close + </button> + </div> + </div> + )} + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/components/wrapped/index.ts b/apps/web/components/wrapped/index.ts new file mode 100644 index 00000000..45d142e1 --- /dev/null +++ b/apps/web/components/wrapped/index.ts @@ -0,0 +1,3 @@ +export { WrappedModal } from "./WrappedModal"; +export { WrappedContent } from "./WrappedContent"; +export { ShareButton } from "./ShareButton"; diff --git a/apps/web/instrumentation.node.ts b/apps/web/instrumentation.node.ts new file mode 100644 index 00000000..2f4c1d58 --- /dev/null +++ b/apps/web/instrumentation.node.ts @@ -0,0 +1,3 @@ +import { initTracing } from "@karakeep/shared-server"; + +initTracing("web"); diff --git a/apps/web/instrumentation.ts b/apps/web/instrumentation.ts new file mode 100644 index 00000000..41630756 --- /dev/null +++ b/apps/web/instrumentation.ts @@ -0,0 +1,5 @@ +export async function register() { + if (process.env.NEXT_RUNTIME === "nodejs") { + await import("./instrumentation.node"); + } +} diff --git a/apps/web/lib/attachments.tsx b/apps/web/lib/attachments.tsx index 67941098..5d7175ec 100644 --- a/apps/web/lib/attachments.tsx +++ b/apps/web/lib/attachments.tsx @@ -2,8 +2,10 @@ import { Archive, Camera, FileCode, + FileText, Image, Paperclip, + SquareUser, Upload, Video, } from "lucide-react"; @@ -12,6 +14,7 @@ import { ZAssetType } from "@karakeep/shared/types/bookmarks"; export const ASSET_TYPE_TO_ICON: Record<ZAssetType, React.ReactNode> = { screenshot: <Camera className="size-4" />, + pdf: <FileText className="size-4" />, assetScreenshot: <Camera className="size-4" />, fullPageArchive: <Archive className="size-4" />, precrawledArchive: <Archive className="size-4" />, @@ -20,5 +23,6 @@ export const ASSET_TYPE_TO_ICON: Record<ZAssetType, React.ReactNode> = { bookmarkAsset: <Paperclip className="size-4" />, linkHtmlContent: <FileCode className="size-4" />, userUploaded: <Upload className="size-4" />, + avatar: <SquareUser className="size-4" />, unknown: <Paperclip className="size-4" />, }; diff --git a/apps/web/lib/auth/client.ts b/apps/web/lib/auth/client.ts new file mode 100644 index 00000000..7e13f798 --- /dev/null +++ b/apps/web/lib/auth/client.ts @@ -0,0 +1,11 @@ +"use client"; + +/** + * Centralized client-side auth utilities. + * This module re-exports next-auth/react functions to allow for easier + * future migration to a different auth provider. + */ + +export { SessionProvider, signIn, signOut, useSession } from "next-auth/react"; + +export type { Session } from "next-auth"; diff --git a/apps/web/lib/bookmark-drag.ts b/apps/web/lib/bookmark-drag.ts new file mode 100644 index 00000000..8ae4a499 --- /dev/null +++ b/apps/web/lib/bookmark-drag.ts @@ -0,0 +1,5 @@ +/** + * MIME type used in HTML5 drag-and-drop dataTransfer to identify + * bookmark card drags (as opposed to file drops). + */ +export const BOOKMARK_DRAG_MIME = "application/x-karakeep-bookmark"; diff --git a/apps/web/lib/bulkActions.ts b/apps/web/lib/bulkActions.ts index 34a236c6..ef814331 100644 --- a/apps/web/lib/bulkActions.ts +++ b/apps/web/lib/bulkActions.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; +import { ZBookmarkList } from "@karakeep/shared/types/lists"; interface BookmarkState { selectedBookmarks: ZBookmark[]; @@ -13,12 +14,15 @@ interface BookmarkState { selectAll: () => void; unSelectAll: () => void; isEverythingSelected: () => boolean; + setListContext: (listContext: ZBookmarkList | undefined) => void; + listContext: ZBookmarkList | undefined; } const useBulkActionsStore = create<BookmarkState>((set, get) => ({ selectedBookmarks: [], visibleBookmarks: [], isBulkEditEnabled: false, + listContext: undefined, toggleBookmark: (bookmark: ZBookmark) => { const selectedBookmarks = get().selectedBookmarks; @@ -57,6 +61,9 @@ const useBulkActionsStore = create<BookmarkState>((set, get) => ({ visibleBookmarks, }); }, + setListContext: (listContext: ZBookmarkList | undefined) => { + set({ listContext }); + }, })); export default useBulkActionsStore; diff --git a/apps/web/lib/clientConfig.tsx b/apps/web/lib/clientConfig.tsx index 9331a7af..ab367be0 100644 --- a/apps/web/lib/clientConfig.tsx +++ b/apps/web/lib/clientConfig.tsx @@ -14,6 +14,8 @@ export const ClientConfigCtx = createContext<ClientConfig>({ inference: { isConfigured: false, inferredTagLang: "english", + enableAutoTagging: false, + enableAutoSummarization: false, }, serverVersion: undefined, disableNewReleaseCheck: true, diff --git a/apps/web/lib/hooks/bookmark-search.ts b/apps/web/lib/hooks/bookmark-search.ts index f94e4691..32882006 100644 --- a/apps/web/lib/hooks/bookmark-search.ts +++ b/apps/web/lib/hooks/bookmark-search.ts @@ -1,9 +1,9 @@ import { useEffect, useMemo, useRef } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useSortOrderStore } from "@/lib/store/useSortOrderStore"; -import { api } from "@/lib/trpc"; -import { keepPreviousData } from "@tanstack/react-query"; +import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { parseSearchQuery } from "@karakeep/shared/searchQueryParser"; import { useInSearchPageStore } from "../store/useInSearchPageStore"; @@ -55,6 +55,7 @@ export function useDoBookmarkSearch() { } export function useBookmarkSearch() { + const api = useTRPC(); const { searchQuery } = useSearchQuery(); const sortOrder = useSortOrderStore((state) => state.sortOrder); @@ -67,17 +68,19 @@ export function useBookmarkSearch() { fetchNextPage, isFetchingNextPage, refetch, - } = api.bookmarks.searchBookmarks.useInfiniteQuery( - { - text: searchQuery, - sortOrder, - }, - { - placeholderData: keepPreviousData, - gcTime: 0, - initialCursor: null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - }, + } = useInfiniteQuery( + api.bookmarks.searchBookmarks.infiniteQueryOptions( + { + text: searchQuery, + sortOrder, + }, + { + placeholderData: keepPreviousData, + gcTime: 0, + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ), ); useEffect(() => { diff --git a/apps/web/lib/hooks/relative-time.ts b/apps/web/lib/hooks/relative-time.ts index f7c38497..8fefa233 100644 --- a/apps/web/lib/hooks/relative-time.ts +++ b/apps/web/lib/hooks/relative-time.ts @@ -1,8 +1,5 @@ import { useEffect, useState } from "react"; -import dayjs from "dayjs"; -import relativeTime from "dayjs/plugin/relativeTime"; - -dayjs.extend(relativeTime); +import { formatDistanceToNow } from "date-fns"; export default function useRelativeTime(date: Date) { const [state, setState] = useState({ @@ -13,7 +10,7 @@ export default function useRelativeTime(date: Date) { // This is to avoid hydration errors when server and clients are in different timezones useEffect(() => { setState({ - fromNow: dayjs(date).fromNow(), + fromNow: formatDistanceToNow(date, { addSuffix: true }), localCreatedAt: date.toLocaleString(), }); }, [date]); diff --git a/apps/web/lib/hooks/useBookmarkImport.ts b/apps/web/lib/hooks/useBookmarkImport.ts index 0d9bbaaf..35c04c1b 100644 --- a/apps/web/lib/hooks/useBookmarkImport.ts +++ b/apps/web/lib/hooks/useBookmarkImport.ts @@ -1,29 +1,17 @@ "use client"; import { useState } from "react"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { - useCreateBookmarkWithPostHook, - useUpdateBookmarkTags, -} from "@karakeep/shared-react/hooks/bookmarks"; -import { - useAddBookmarkToList, - useCreateBookmarkList, -} from "@karakeep/shared-react/hooks/lists"; -import { api } from "@karakeep/shared-react/trpc"; +import { useCreateBookmarkList } from "@karakeep/shared-react/hooks/lists"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { importBookmarksFromFile, ImportSource, - ParsedBookmark, parseImportFile, } from "@karakeep/shared/import-export"; -import { - BookmarkTypes, - MAX_BOOKMARK_TITLE_LENGTH, -} from "@karakeep/shared/types/bookmarks"; import { useCreateImportSession } from "./useImportSessions"; @@ -34,18 +22,22 @@ export interface ImportProgress { export function useBookmarkImport() { const { t } = useTranslation(); + const api = useTRPC(); const [importProgress, setImportProgress] = useState<ImportProgress | null>( null, ); const [quotaError, setQuotaError] = useState<string | null>(null); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); const { mutateAsync: createImportSession } = useCreateImportSession(); - const { mutateAsync: createBookmark } = useCreateBookmarkWithPostHook(); const { mutateAsync: createList } = useCreateBookmarkList(); - const { mutateAsync: addToList } = useAddBookmarkToList(); - const { mutateAsync: updateTags } = useUpdateBookmarkTags(); + const { mutateAsync: stageImportedBookmarks } = useMutation( + api.importSessions.stageImportedBookmarks.mutationOptions(), + ); + const { mutateAsync: finalizeImportStaging } = useMutation( + api.importSessions.finalizeImportStaging.mutationOptions(), + ); const uploadBookmarkFileMutation = useMutation({ mutationFn: async ({ @@ -65,8 +57,9 @@ export function useBookmarkImport() { // Check quota before proceeding if (bookmarkCount > 0) { - const quotaUsage = - await apiUtils.client.subscriptions.getQuotaUsage.query(); + const quotaUsage = await queryClient.fetchQuery( + api.subscriptions.getQuotaUsage.queryOptions(), + ); if ( !quotaUsage.bookmarks.unlimited && @@ -84,7 +77,6 @@ export function useBookmarkImport() { } // Proceed with import if quota check passes - // Use a custom parser to avoid re-parsing the file const result = await importBookmarksFromFile( { file, @@ -93,65 +85,9 @@ export function useBookmarkImport() { deps: { createImportSession, createList, - createBookmark: async ( - bookmark: ParsedBookmark, - sessionId: string, - ) => { - if (bookmark.content === undefined) { - throw new Error("Content is undefined"); - } - const created = await createBookmark({ - crawlPriority: "low", - title: bookmark.title.substring(0, MAX_BOOKMARK_TITLE_LENGTH), - createdAt: bookmark.addDate - ? new Date(bookmark.addDate * 1000) - : undefined, - note: bookmark.notes, - archived: bookmark.archived, - importSessionId: sessionId, - source: "import", - ...(bookmark.content.type === BookmarkTypes.LINK - ? { - type: BookmarkTypes.LINK, - url: bookmark.content.url, - } - : { - type: BookmarkTypes.TEXT, - text: bookmark.content.text, - }), - }); - return created as { id: string; alreadyExists?: boolean }; - }, - addBookmarkToLists: async ({ - bookmarkId, - listIds, - }: { - bookmarkId: string; - listIds: string[]; - }) => { - await Promise.all( - listIds.map((listId) => - addToList({ - bookmarkId, - listId, - }), - ), - ); - }, - updateBookmarkTags: async ({ - bookmarkId, - tags, - }: { - bookmarkId: string; - tags: string[]; - }) => { - if (tags.length > 0) { - await updateTags({ - bookmarkId, - attach: tags.map((t) => ({ tagName: t })), - detach: [], - }); - } + stageImportedBookmarks, + finalizeImportStaging: async (sessionId: string) => { + await finalizeImportStaging({ importSessionId: sessionId }); }, }, onProgress: (done, total) => setImportProgress({ done, total }), @@ -172,19 +108,11 @@ export function useBookmarkImport() { toast({ description: "No bookmarks found in the file." }); return; } - const { successes, failures, alreadyExisted } = result.counts; - if (successes > 0 || alreadyExisted > 0) { - toast({ - description: `Imported ${successes} bookmarks into import session. Background processing will start automatically.`, - variant: "default", - }); - } - if (failures > 0) { - toast({ - description: `Failed to import ${failures} bookmarks. Check console for details.`, - variant: "destructive", - }); - } + + toast({ + description: `Staged ${result.counts.total} bookmarks for import. Background processing will start automatically.`, + variant: "default", + }); }, onError: (error) => { setImportProgress(null); diff --git a/apps/web/lib/hooks/useImportSessions.ts b/apps/web/lib/hooks/useImportSessions.ts index cee99bbc..2cc632ad 100644 --- a/apps/web/lib/hooks/useImportSessions.ts +++ b/apps/web/lib/hooks/useImportSessions.ts @@ -1,62 +1,151 @@ "use client"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; -import { api } from "@karakeep/shared-react/trpc"; +import { useTRPC } from "@karakeep/shared-react/trpc"; export function useCreateImportSession() { - const apiUtils = api.useUtils(); + const api = useTRPC(); + const queryClient = useQueryClient(); - return api.importSessions.createImportSession.useMutation({ - onSuccess: () => { - apiUtils.importSessions.listImportSessions.invalidate(); - }, - onError: (error) => { - toast({ - description: error.message || "Failed to create import session", - variant: "destructive", - }); - }, - }); + return useMutation( + api.importSessions.createImportSession.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries( + api.importSessions.listImportSessions.pathFilter(), + ); + }, + onError: (error) => { + toast({ + description: error.message || "Failed to create import session", + variant: "destructive", + }); + }, + }), + ); } export function useListImportSessions() { - return api.importSessions.listImportSessions.useQuery( - {}, - { - select: (data) => data.sessions, - }, + const api = useTRPC(); + return useQuery( + api.importSessions.listImportSessions.queryOptions( + {}, + { + select: (data) => data.sessions, + }, + ), ); } export function useImportSessionStats(importSessionId: string) { - return api.importSessions.getImportSessionStats.useQuery( - { - importSessionId, - }, - { - refetchInterval: 5000, // Refetch every 5 seconds to show progress - enabled: !!importSessionId, - }, + const api = useTRPC(); + return useQuery( + api.importSessions.getImportSessionStats.queryOptions( + { + importSessionId, + }, + { + refetchInterval: (q) => + !q.state.data || + !["completed", "failed"].includes(q.state.data.status) + ? 5000 + : false, // Refetch every 5 seconds to show progress + enabled: !!importSessionId, + }, + ), ); } export function useDeleteImportSession() { - const apiUtils = api.useUtils(); + const api = useTRPC(); + const queryClient = useQueryClient(); + + return useMutation( + api.importSessions.deleteImportSession.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries( + api.importSessions.listImportSessions.pathFilter(), + ); + toast({ + description: "Import session deleted successfully", + variant: "default", + }); + }, + onError: (error) => { + toast({ + description: error.message || "Failed to delete import session", + variant: "destructive", + }); + }, + }), + ); +} - return api.importSessions.deleteImportSession.useMutation({ - onSuccess: () => { - apiUtils.importSessions.listImportSessions.invalidate(); - toast({ - description: "Import session deleted successfully", - variant: "default", - }); - }, - onError: (error) => { - toast({ - description: error.message || "Failed to delete import session", - variant: "destructive", - }); - }, - }); +export function usePauseImportSession() { + const api = useTRPC(); + const queryClient = useQueryClient(); + + return useMutation( + api.importSessions.pauseImportSession.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries( + api.importSessions.listImportSessions.pathFilter(), + ); + toast({ + description: "Import session paused", + variant: "default", + }); + }, + onError: (error) => { + toast({ + description: error.message || "Failed to pause import session", + variant: "destructive", + }); + }, + }), + ); +} + +export function useResumeImportSession() { + const api = useTRPC(); + const queryClient = useQueryClient(); + + return useMutation( + api.importSessions.resumeImportSession.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries( + api.importSessions.listImportSessions.pathFilter(), + ); + toast({ + description: "Import session resumed", + variant: "default", + }); + }, + onError: (error) => { + toast({ + description: error.message || "Failed to resume import session", + variant: "destructive", + }); + }, + }), + ); +} + +export function useImportSessionResults( + importSessionId: string, + filter: "all" | "accepted" | "rejected" | "skipped_duplicate" | "pending", +) { + const api = useTRPC(); + return useInfiniteQuery( + api.importSessions.getImportSessionResults.infiniteQueryOptions( + { importSessionId, filter, limit: 50 }, + { getNextPageParam: (lastPage) => lastPage.nextCursor }, + ), + ); } diff --git a/apps/web/lib/i18n/client.ts b/apps/web/lib/i18n/client.ts index 1c56a88a..0704ce87 100644 --- a/apps/web/lib/i18n/client.ts +++ b/apps/web/lib/i18n/client.ts @@ -4,6 +4,7 @@ import i18next from "i18next"; import resourcesToBackend from "i18next-resources-to-backend"; import { initReactI18next, + Trans as TransOrg, useTranslation as useTranslationOrg, } from "react-i18next"; @@ -30,4 +31,5 @@ i18next }); export const useTranslation = useTranslationOrg; +export const Trans = TransOrg; export const i18n = i18next; diff --git a/apps/web/lib/i18n/locales/ar/translation.json b/apps/web/lib/i18n/locales/ar/translation.json index 023d6f15..e2d9eb7e 100644 --- a/apps/web/lib/i18n/locales/ar/translation.json +++ b/apps/web/lib/i18n/locales/ar/translation.json @@ -39,7 +39,9 @@ "updated_at": "تم التحديث في", "quota": "حصة", "bookmarks": "الإشارات المرجعية", - "storage": "تخزين" + "storage": "تخزين", + "pdf": "نسخة PDF مؤرشفة", + "default": "افتراضي" }, "layouts": { "masonry": "متعدد الأعمدة", @@ -90,7 +92,9 @@ "confirm": "تأكيد", "regenerate": "تجديد", "load_more": "المزيد", - "edit_notes": "تحرير الملاحظات" + "edit_notes": "تحرير الملاحظات", + "preserve_as_pdf": "حفظ كملف PDF", + "offline_copies": "نسخ غير متصلة بالإنترنت" }, "highlights": { "no_highlights": "ليس لديك أي تمييزات بعد." @@ -119,6 +123,49 @@ "show": "اعرض الإشارات المرجعية المؤرشفة في العلامات والقوائم", "hide": "إخفاء الإشارات المرجعية المؤرشفة في العلامات والقوائم" } + }, + "reader_settings": { + "local_overrides_title": "إعدادات خاصة بالجهاز مُفعلة", + "using_default": "استخدام الإعدادات الافتراضية للعميل", + "clear_override_hint": "امسح تجاوز الجهاز لاستخدام الإعداد العام ({{value}})", + "font_size": "حجم الخط", + "font_family": "نوع الخط", + "preview_inline": "(معاينة)", + "tooltip_preview": "تغييرات المعاينة غير المحفوظة", + "save_to_all_devices": "كل الأجهزة", + "tooltip_local": "إعدادات الجهاز تختلف عن الإعدادات العامة", + "reset_preview": "إعادة ضبط المعاينة", + "mono": "Monospace", + "line_height": "ارتفاع السطر", + "tooltip_default": "إعدادات القراءة", + "title": "إعدادات القارئ", + "serif": "Serif", + "preview": "معاينة", + "not_set": "غير مضبوط", + "clear_local_overrides": "مسح إعدادات الجهاز", + "preview_text": "الـ quick brown fox jumps over the lazy dog. ستظهر نصوص عرض القارئ بهذه الطريقة.", + "local_overrides_cleared": "تم مسح إعدادات الجهاز المخصصة", + "local_overrides_description": "يحتوي هذا الجهاز على إعدادات قارئ مختلفة عن الإعدادات الافتراضية العامة:", + "clear_defaults": "مسح كل الإعدادات الافتراضية", + "description": "اضبط إعدادات النص الافتراضية لعرض القارئ. تتم مزامنة هذه الإعدادات عبر جميع أجهزتك.", + "defaults_cleared": "تم مسح الإعدادات الافتراضية للقارئ", + "save_hint": "احفظ الإعدادات لهذا الجهاز فقط أو قم بمزامنتها عبر جميع الأجهزة", + "save_as_default": "حفظ كافتراضي", + "save_to_device": "هذا الجهاز", + "sans": "Sans Serif", + "tooltip_preview_and_local": "تغييرات المعاينة غير المحفوظة؛ إعدادات الجهاز تختلف عن الإعدادات العامة", + "adjust_hint": "اضبط الإعدادات أعلاه لمعاينة التغييرات" + }, + "avatar": { + "upload": "ارفع الصورة الرمزية", + "change": "غير الصورة الرمزية", + "remove_confirm_title": "تشيل الصورة الرمزية؟", + "updated": "تم تحديث الصورة الرمزية", + "removed": "تمت إزالة الصورة الرمزية", + "description": "ارفع صورة مربعة عشان تستخدمها كصورة رمزية.", + "remove_confirm_description": "ده هيمسح صورة ملفك الشخصي الحالية.", + "title": "صورة الملف الشخصي", + "remove": "شيل الصورة الرمزية" } }, "ai": { @@ -132,7 +179,21 @@ "all_tagging": "التوسيم الشامل", "text_tagging": "توسيم النصوص", "image_tagging": "توسيم الصور", - "summarization": "التلخيص" + "summarization": "التلخيص", + "tag_style": "نمط العلامة", + "auto_summarization_description": "إنشاء ملخصات تلقائيًا لعلاماتك المرجعية باستخدام الذكاء الاصطناعي.", + "auto_tagging": "وضع العلامات التلقائي", + "titlecase_spaces": "أحرف استهلالية مع مسافات", + "lowercase_underscores": "أحرف صغيرة مع شرطات سفلية", + "inference_language": "لُغة الاستنتاج", + "titlecase_hyphens": "أحرف استهلالية مع واصلات", + "lowercase_hyphens": "أحرف صغيرة مع واصلات", + "lowercase_spaces": "أحرف صغيرة مع مسافات", + "inference_language_description": "اختر اللغة الخاصة بالعلامات والملخصات التي تم إنشاؤها بواسطة الذكاء الاصطناعي.", + "tag_style_description": "اختر كيف ينبغي تنسيق علاماتك التي تم إنشاؤها تلقائيًا.", + "auto_tagging_description": "إنشاء علامات تلقائيًا لعلاماتك المرجعية باستخدام الذكاء الاصطناعي.", + "camelCase": "camelCase", + "auto_summarization": "التلخيص التلقائي" }, "feeds": { "rss_subscriptions": "اشتراكات RSS", @@ -163,6 +224,7 @@ "import_export_bookmarks": "استيراد / تصدير الإشارات المرجعية", "import_bookmarks_from_html_file": "استيراد إشارات مرجعية من ملف HTML", "import_bookmarks_from_pocket_export": "استيراد إشارات مرجعية من تصدير Pocket", + "import_bookmarks_from_matter_export": "استيراد إشارات مرجعية من تصدير Matter", "import_bookmarks_from_omnivore_export": "استيراد إشارات مرجعية من تصدير Omnivore", "import_bookmarks_from_linkwarden_export": "استيراد إشارات مرجعية من تصدير Linkwarden", "import_bookmarks_from_karakeep_export": "استيراد إشارات مرجعية من تصدير Karakeep", @@ -680,7 +742,14 @@ "week_s_ago": " منذ أسبوع (أسابيع)", "history": "عمليات البحث الأخيرة", "title_contains": "العنوان يحتوي على", - "title_does_not_contain": "العنوان لا يحتوي على" + "title_does_not_contain": "العنوان لا يحتوي على", + "is_broken_link": "لديه رابط معطّل", + "tags": "العلامات", + "no_suggestions": "لا توجد اقتراحات", + "filters": "الفلاتر", + "is_not_broken_link": "لديه رابط صالح", + "lists": "القوائم", + "feeds": "خلاصات الأخبار" }, "preview": { "view_original": "عرض النسخة الأصلية", @@ -689,7 +758,8 @@ "tabs": { "content": "المحتوى", "details": "التفاصيل" - } + }, + "archive_info": "قد لا يتم عرض الأرشيفات بشكل صحيح في السطر إذا كانت تتطلب Javascript. للحصول على أفضل النتائج، <1>قم بتنزيلها وافتحها في متصفحك</1>." }, "editor": { "quickly_focus": "يمكنك التركيز سريعاً على هذا الحقل بالضغط على ⌘ + E", @@ -763,7 +833,8 @@ "refetch": "تم إضافة إعادة الجلب إلى قائمة الانتظار!", "full_page_archive": "تم بدء إنشاء أرشيف الصفحة الكامل", "delete_from_list": "تم حذف الإشارة المرجعية من القائمة", - "clipboard_copied": "تم نسخ الرابط إلى الحافظة!" + "clipboard_copied": "تم نسخ الرابط إلى الحافظة!", + "preserve_pdf": "تم تشغيل حفظ PDF" }, "lists": { "created": "تم إنشاء القائمة!", diff --git a/apps/web/lib/i18n/locales/cs/translation.json b/apps/web/lib/i18n/locales/cs/translation.json index b0df5dab..f13b2100 100644 --- a/apps/web/lib/i18n/locales/cs/translation.json +++ b/apps/web/lib/i18n/locales/cs/translation.json @@ -39,7 +39,9 @@ }, "quota": "Kvóta", "bookmarks": "Záložky", - "storage": "Úložiště" + "storage": "Úložiště", + "pdf": "Archivovaný PDF", + "default": "Výchozí" }, "actions": { "close": "Zavřít", @@ -84,7 +86,9 @@ "confirm": "Potvrdit", "regenerate": "Regenerovat", "load_more": "Načíst další", - "edit_notes": "Upravit poznámky" + "edit_notes": "Upravit poznámky", + "preserve_as_pdf": "Uložit jako PDF", + "offline_copies": "Offline kopie" }, "settings": { "ai": { @@ -98,7 +102,21 @@ "all_tagging": "Všechny štítky", "text_tagging": "Označování textu", "image_tagging": "Označování obrázků", - "summarization": "Shrnutí" + "summarization": "Shrnutí", + "tag_style": "Styl štítků", + "auto_summarization_description": "Automaticky generovat shrnutí pro tvoje záložky pomocí umělý inteligence.", + "auto_tagging": "Automatický štítkování", + "titlecase_spaces": "Velká písmena s mezerami", + "lowercase_underscores": "Malá písmena s podtržítky", + "inference_language": "Jazyk pro odvozování", + "titlecase_hyphens": "Velká písmena s pomlčkami", + "lowercase_hyphens": "Malá písmena s pomlčkami", + "lowercase_spaces": "Malá písmena s mezerami", + "inference_language_description": "Vyber jazyk pro štítky a souhrny generované AI.", + "tag_style_description": "Vyber si, jakým způsobem se mají automaticky generované štítky formátovat.", + "auto_tagging_description": "Automaticky generovat štítky pro tvoje záložky pomocí umělý inteligence.", + "camelCase": "camelCase", + "auto_summarization": "Automatický shrnutí" }, "webhooks": { "webhooks": "Webhooky", @@ -210,7 +228,50 @@ "new_password": "Nový heslo", "confirm_new_password": "Potvrďte nový heslo", "options": "Možnosti", - "interface_lang": "Jazyk rozhraní" + "interface_lang": "Jazyk rozhraní", + "reader_settings": { + "local_overrides_title": "Aktivní nastavení specifická pro zařízení", + "using_default": "Používám výchozí nastavení klienta", + "clear_override_hint": "Vymažte přepsání zařízení, abyste použili globální nastavení ({{value}})", + "font_size": "Velikost písma", + "font_family": "Rodina písem", + "preview_inline": "(náhled)", + "tooltip_preview": "Neuložené změny náhledu", + "save_to_all_devices": "Všechna zařízení", + "tooltip_local": "Nastavení zařízení se liší od globálních", + "reset_preview": "Obnovit náhled", + "mono": "Neproporcionální", + "line_height": "Výška řádku", + "tooltip_default": "Nastavení čtení", + "title": "Nastavení čtečky", + "serif": "Patkové", + "preview": "Náhled", + "not_set": "Nenastaveno", + "clear_local_overrides": "Vymazat nastavení zařízení", + "preview_text": "Příliš žluťoučký kůň úpěl ďábelské ódy. Takto bude vypadat text v zobrazení čtečky.", + "local_overrides_cleared": "Nastavení specifická pro zařízení byla vymazána", + "local_overrides_description": "Toto zařízení má nastavení čtečky, která se liší od výchozích:", + "clear_defaults": "Smazat všechna výchozí nastavení", + "description": "Nastav výchozí nastavení textu pro zobrazení v čtečce. Tato nastavení se synchronizují na všech tvých zařízeních.", + "defaults_cleared": "Výchozí nastavení čtečky byla vymazána", + "save_hint": "Uložit nastavení jen pro toto zařízení, nebo synchronizovat na všech zařízeních", + "save_as_default": "Uložit jako výchozí", + "save_to_device": "Toto zařízení", + "sans": "Bezpatkové", + "tooltip_preview_and_local": "Neuložené změny náhledu; nastavení zařízení se liší od globálních", + "adjust_hint": "Upravte nastavení výše, abyste si prohlédli změny" + }, + "avatar": { + "upload": "Nahrát avatara", + "change": "Změnit avatara", + "remove_confirm_title": "Odebrat avatara?", + "updated": "Avatar aktualizován", + "removed": "Avatar byl odebrán", + "description": "Nahrajte čtvercový obrázek, který se použije jako váš avatar.", + "remove_confirm_description": "Tímto vymažete vaši aktuální profilovou fotku.", + "title": "Profilová fotka", + "remove": "Odebrat avatara" + } }, "feeds": { "rss_subscriptions": "RSS odběry", @@ -223,6 +284,7 @@ "import_export_bookmarks": "Import / Export záložek", "import_bookmarks_from_html_file": "Importovat záložky z HTML souboru", "import_bookmarks_from_pocket_export": "Importovat záložky z exportu Pocket", + "import_bookmarks_from_matter_export": "Importovat záložky z exportu Matter", "import_bookmarks_from_omnivore_export": "Importovat záložky z Omnivore exportu", "import_bookmarks_from_linkwarden_export": "Importovat záložky z exportu Linkwarden", "import_bookmarks_from_karakeep_export": "Importovat záložky z exportu Karakeep", @@ -537,7 +599,14 @@ "or": "Nebo", "history": "Poslední hledání", "title_contains": "Název obsahuje", - "title_does_not_contain": "Název neobsahuje" + "title_does_not_contain": "Název neobsahuje", + "is_broken_link": "Má nefunkční odkaz", + "tags": "Štítky", + "no_suggestions": "Žádné návrhy", + "filters": "Filtry", + "is_not_broken_link": "Má funkční odkaz", + "lists": "Seznamy", + "feeds": "Kanály" }, "editor": { "disabled_submissions": "Odesílání příspěvků je zakázáno", @@ -605,7 +674,8 @@ "refetch": "Opětovné načtení bylo zařazeno do fronty!", "full_page_archive": "Vytváření archivu celé stránky bylo spuštěno", "delete_from_list": "Záložka byla ze seznamu smazána", - "clipboard_copied": "Odkaz byl přidán do schránky!" + "clipboard_copied": "Odkaz byl přidán do schránky!", + "preserve_pdf": "Ukládání do PDF spuštěno" }, "lists": { "created": "Seznam byl vytvořen!", @@ -778,7 +848,8 @@ "tabs": { "content": "Obsah", "details": "Podrobnosti" - } + }, + "archive_info": "Archivy se nemusí vykreslovat správně inline, pokud vyžadují Javascript. Pro nejlepší výsledky si <1>stáhněte a otevřete v prohlížeči</1>." }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/da/translation.json b/apps/web/lib/i18n/locales/da/translation.json index 0026b4d3..be382f86 100644 --- a/apps/web/lib/i18n/locales/da/translation.json +++ b/apps/web/lib/i18n/locales/da/translation.json @@ -42,7 +42,9 @@ "confirm": "Bekræft", "regenerate": "Regenerér", "load_more": "Indlæs mere", - "edit_notes": "Rediger noter" + "edit_notes": "Rediger noter", + "preserve_as_pdf": "Bevar som PDF", + "offline_copies": "Offline kopier" }, "settings": { "import": { @@ -53,6 +55,7 @@ "import_export_bookmarks": "Import / eksport bogmærker", "import_bookmarks_from_html_file": "Importer bogmærker fra HTML-fil", "import_bookmarks_from_pocket_export": "Importer bogmærker fra Pocket-eksport", + "import_bookmarks_from_matter_export": "Importer bogmærker fra Matter-eksport", "imported_bookmarks": "Importerede bogmærker", "import_bookmarks_from_linkwarden_export": "Importer bogmærker fra Linkwarden-eksport", "import_bookmarks_from_tab_session_manager_export": "Importer bogmærker fra Tab Session Manager", @@ -80,6 +83,49 @@ "show": "Vis arkiverede bogmærker i tags og lister", "hide": "Skjul arkiverede bogmærker i tags og lister" } + }, + "reader_settings": { + "local_overrides_title": "Apparatspecifikke indstillinger er aktive", + "using_default": "Bruger klientstandard", + "clear_override_hint": "Ryd tilsidesættelsen af enheden for at bruge den globale indstilling ({{value}})", + "font_size": "Skriftstørrelse", + "font_family": "Skrifttype", + "preview_inline": "(forhåndsvisning)", + "tooltip_preview": "Ikke-gemte ændringer i forhåndsvisning", + "save_to_all_devices": "Alle enheder", + "tooltip_local": "Enhedsindstillinger adskiller sig fra globale", + "reset_preview": "Nulstil forhåndsvisning", + "mono": "Monospace", + "line_height": "Linjehøjde", + "tooltip_default": "Læseindstillinger", + "title": "Læserindstillinger", + "serif": "Serif", + "preview": "Forhåndsvisning", + "not_set": "Ikke angivet", + "clear_local_overrides": "Ryd enhedsindstillinger", + "preview_text": "\"The quick brown fox jumps over the lazy dog.\" Sådan vises din tekst i læsevisning.", + "local_overrides_cleared": "Apparatspecifikke indstillinger er blevet ryddet", + "local_overrides_description": "Denne enhed har læserindstillinger, der afviger fra dine globale standardindstillinger:", + "clear_defaults": "Ryd alle standarder", + "description": "Konfigurer standard tekstindstillinger for læsevisningen. Disse indstillinger synkroniseres på tværs af alle dine enheder.", + "defaults_cleared": "Læserstandarder er blevet ryddet", + "save_hint": "Gem indstillinger kun for denne enhed eller synkroniser på tværs af alle enheder", + "save_as_default": "Gem som standard", + "save_to_device": "Denne enhed", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Ikke-gemte ændringer i forhåndsvisning; enhedsindstillinger adskiller sig fra globale", + "adjust_hint": "Juster indstillingerne ovenfor for at se et eksempel på ændringerne" + }, + "avatar": { + "upload": "Upload avatar", + "change": "Skift avatar", + "remove_confirm_title": "Fjern avatar?", + "updated": "Avatar opdateret", + "removed": "Avatar fjernet", + "description": "Upload et firkantet billede, som du kan bruge som din avatar.", + "remove_confirm_description": "Dette vil fjerne dit nuværende profilbillede.", + "title": "Profilbillede", + "remove": "Fjern avatar" } }, "feeds": { @@ -99,7 +145,21 @@ "summarization": "Opsummering", "all_tagging": "Tagging for alle typer", "text_tagging": "Tekst-tagging", - "image_tagging": "Billede-tagging" + "image_tagging": "Billede-tagging", + "tag_style": "Tag-stil", + "auto_summarization_description": "Generér automatisk opsummeringer til dine bogmærker ved hjælp af AI.", + "auto_tagging": "Automatisk taggning", + "titlecase_spaces": "Store forbogstaver med mellemrum", + "lowercase_underscores": "Små bogstaver med understregninger", + "inference_language": "Inferenssprog", + "titlecase_hyphens": "Store forbogstaver med bindestreger", + "lowercase_hyphens": "Små bogstaver med bindestreger", + "lowercase_spaces": "Små bogstaver med mellemrum", + "inference_language_description": "Vælg sprog for AI-genererede tags og opsummeringer.", + "tag_style_description": "Vælg, hvordan dine automatisk genererede tags skal formateres.", + "auto_tagging_description": "Generér automatisk tags til dine bogmærker ved hjælp af AI.", + "camelCase": "camelCase", + "auto_summarization": "Automatisk opsummering" }, "broken_links": { "crawling_status": "Gennemsøgningsstatus", @@ -604,7 +664,9 @@ "summary": "Opsummering", "quota": "Kvote", "bookmarks": "Bogmærker", - "storage": "Lagring" + "storage": "Lagring", + "pdf": "Arkiveret PDF", + "default": "Standard" }, "layouts": { "masonry": "Fliser", @@ -705,7 +767,8 @@ "tabs": { "content": "Indhold", "details": "Detaljer" - } + }, + "archive_info": "Arkiver gengives muligvis ikke korrekt inline, hvis de kræver Javascript. For at opnå de bedste resultater skal du <1>downloade den og åbne den i din browser</1>." }, "toasts": { "bookmarks": { @@ -714,7 +777,8 @@ "delete_from_list": "Bogmærket er blevet slettet fra listen", "deleted": "Bogmærket er blevet slettet!", "clipboard_copied": "Linket er kopieret til din udklipsholder!", - "updated": "Bogmærket er blevet opdateret!" + "updated": "Bogmærket er blevet opdateret!", + "preserve_pdf": "PDF-bevaring er blevet udløst" }, "lists": { "created": "Listen er oprettet!", @@ -775,7 +839,14 @@ "year_s_ago": " År siden", "history": "Seneste søgninger", "title_contains": "Titel indeholder", - "title_does_not_contain": "Titel indeholder ikke" + "title_does_not_contain": "Titel indeholder ikke", + "is_broken_link": "Har Beskadet Link", + "tags": "Tags", + "no_suggestions": "Ingen forslag", + "filters": "Filtre", + "is_not_broken_link": "Har Fungerende Link", + "lists": "Lister", + "feeds": "Feeds" }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/de/translation.json b/apps/web/lib/i18n/locales/de/translation.json index 88bbe275..7192b89e 100644 --- a/apps/web/lib/i18n/locales/de/translation.json +++ b/apps/web/lib/i18n/locales/de/translation.json @@ -39,7 +39,9 @@ "summary": "Zusammenfassung", "quota": "Kontingent", "bookmarks": "Lesezeichen", - "storage": "Speicher" + "storage": "Speicher", + "pdf": "Archivierte PDF-Datei", + "default": "Standard" }, "layouts": { "masonry": "Verschachtelt", @@ -90,7 +92,9 @@ "confirm": "Bestätigen", "regenerate": "Regenerieren", "load_more": "Mehr laden", - "edit_notes": "Notizen bearbeiten" + "edit_notes": "Notizen bearbeiten", + "preserve_as_pdf": "Als PDF speichern", + "offline_copies": "Offline-Kopien" }, "settings": { "back_to_app": "Zurück zur App", @@ -116,6 +120,49 @@ "show": "Archivierte Lesezeichen in Tags und Listen anzeigen", "hide": "Archivierte Lesezeichen in Tags und Listen ausblenden" } + }, + "reader_settings": { + "local_overrides_title": "Gerätespezifische Einstellungen aktiv", + "using_default": "Client-Standard verwenden", + "clear_override_hint": "Geräteüberschreibung löschen, um die globale Einstellung zu verwenden ({{value}})", + "font_size": "Schriftgröße", + "font_family": "Schriftfamilie", + "preview_inline": "(Vorschau)", + "tooltip_preview": "Nicht gespeicherte Vorschaueinstellungen", + "save_to_all_devices": "Alle Geräte", + "tooltip_local": "Geräteeinstellungen weichen von den globalen Einstellungen ab", + "reset_preview": "Vorschau zurücksetzen", + "mono": "Monospace", + "line_height": "Zeilenhöhe", + "tooltip_default": "Leseeinstellungen", + "title": "Lesereinstellungen", + "serif": "Serif", + "preview": "Vorschau", + "not_set": "Nicht festgelegt", + "clear_local_overrides": "Geräteeinstellungen löschen", + "preview_text": "The quick brown fox jumps over the lazy dog. So wird der Text Ihrer Leseransicht aussehen.", + "local_overrides_cleared": "Gerätespezifische Einstellungen wurden gelöscht", + "local_overrides_description": "Dieses Gerät hat Lesereinstellungen, die von Ihren globalen Standardeinstellungen abweichen:", + "clear_defaults": "Alle Standardeinstellungen löschen", + "description": "Standard-Texteinstellungen für die Leseransicht konfigurieren. Diese Einstellungen werden auf allen Ihren Geräten synchronisiert.", + "defaults_cleared": "Die Standardeinstellungen des Readers wurden gelöscht", + "save_hint": "Einstellungen nur für dieses Gerät speichern oder über alle Geräte synchronisieren", + "save_as_default": "Als Standard speichern", + "save_to_device": "Dieses Gerät", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Nicht gespeicherte Vorschaueinstellungen; Geräteeinstellungen weichen von den globalen Einstellungen ab", + "adjust_hint": "Passe die Einstellungen oben an, um eine Vorschau der Änderungen zu sehen" + }, + "avatar": { + "upload": "Avatar hochladen", + "change": "Avatar ändern", + "remove_confirm_title": "Avatar entfernen?", + "updated": "Avatar aktualisiert", + "removed": "Avatar entfernt", + "description": "Lade ein quadratisches Bild hoch, das du als Avatar verwenden möchtest.", + "remove_confirm_description": "Dadurch wird dein aktuelles Profilfoto gelöscht.", + "title": "Profilfoto", + "remove": "Avatar entfernen" } }, "ai": { @@ -129,7 +176,21 @@ "all_tagging": "Gesamtes Tagging", "text_tagging": "Text-Tagging", "image_tagging": "Bild-Tagging", - "summarization": "Zusammenfassung" + "summarization": "Zusammenfassung", + "tag_style": "Tag-Stil", + "auto_summarization_description": "Automatische Zusammenfassung deiner Lesezeichen mithilfe von KI.", + "auto_tagging": "Automatisches Tagging", + "titlecase_spaces": "Titel-Schreibweise mit Leerzeichen", + "lowercase_underscores": "Kleinbuchstaben mit Unterstrichen", + "inference_language": "Schlussfolgerungs-Sprache", + "titlecase_hyphens": "Titel-Schreibweise mit Bindestrichen", + "lowercase_hyphens": "Kleinbuchstaben mit Bindestrichen", + "lowercase_spaces": "Kleinbuchstaben mit Leerzeichen", + "inference_language_description": "Sprache für von KI generierte Tags und Zusammenfassungen auswählen.", + "tag_style_description": "Wähle, wie deine automatisch generierten Tags formatiert werden sollen.", + "auto_tagging_description": "Automatische Tag-Generierung für deine Lesezeichen mithilfe von KI.", + "camelCase": "camelCase", + "auto_summarization": "Automatische Zusammenfassung" }, "feeds": { "rss_subscriptions": "RSS-Abonnements", @@ -142,6 +203,7 @@ "import_export_bookmarks": "Lesezeichen importieren / exportieren", "import_bookmarks_from_html_file": "Lesezeichen aus HTML-Datei importieren", "import_bookmarks_from_pocket_export": "Lesezeichen aus Pocket-Export importieren", + "import_bookmarks_from_matter_export": "Lesezeichen aus Matter-Export importieren", "import_bookmarks_from_omnivore_export": "Lesezeichen aus Omnivore-Export importieren", "import_bookmarks_from_karakeep_export": "Lesezeichen aus Karakeep-Export importieren", "export_links_and_notes": "Links und Notizen exportieren", @@ -646,7 +708,8 @@ "tabs": { "content": "Inhalt", "details": "Details" - } + }, + "archive_info": "Archive werden möglicherweise nicht korrekt inline dargestellt, wenn sie Javascript benötigen. Die besten Ergebnisse erzielst du, wenn du sie <1>herunterlädst und in deinem Browser öffnest</1>." }, "editor": { "quickly_focus": "Sie können schnell auf dieses Feld fokussieren, indem Sie ⌘ + E drücken", @@ -714,7 +777,8 @@ "refetch": "Neuabruf wurde in die Warteschlange gestellt!", "full_page_archive": "Erstellung des vollständigen Seitenarchivs wurde ausgelöst", "delete_from_list": "Das Lesezeichen wurde aus der Liste gelöscht", - "clipboard_copied": "Link wurde in Ihre Zwischenablage kopiert!" + "clipboard_copied": "Link wurde in Ihre Zwischenablage kopiert!", + "preserve_pdf": "Die PDF-Speicherung wurde ausgelöst" }, "lists": { "created": "Liste wurde erstellt!", @@ -781,7 +845,14 @@ "year_s_ago": " Vor Jahr(en)", "history": "Letzte Suchanfragen", "title_contains": "Titel enthält", - "title_does_not_contain": "Titel enthält nicht" + "title_does_not_contain": "Titel enthält nicht", + "is_broken_link": "Hat defekten Link", + "tags": "Schlagwörter", + "no_suggestions": "Keine Vorschläge", + "filters": "Filter", + "is_not_broken_link": "Hat funktionierenden Link", + "lists": "Listen", + "feeds": "Feeds" }, "bookmark_editor": { "subtitle": "Ändere die Details des Lesezeichens. Klicke auf Speichern, wenn du fertig bist.", diff --git a/apps/web/lib/i18n/locales/el/translation.json b/apps/web/lib/i18n/locales/el/translation.json index 203e0f55..6fea6c6e 100644 --- a/apps/web/lib/i18n/locales/el/translation.json +++ b/apps/web/lib/i18n/locales/el/translation.json @@ -39,7 +39,9 @@ }, "quota": "Ποσόστωση", "bookmarks": "Σελιδοδείκτες", - "storage": "Αποθήκευση" + "storage": "Αποθήκευση", + "pdf": "Αρχειοθετημένο PDF", + "default": "Προεπιλογή" }, "layouts": { "masonry": "Πλινθοδομή", @@ -90,7 +92,9 @@ "confirm": "Επιβεβαίωση", "regenerate": "Ανανέωση", "load_more": "Φόρτωσε περισσότερα", - "edit_notes": "Επεξεργασία σημειώσεων" + "edit_notes": "Επεξεργασία σημειώσεων", + "preserve_as_pdf": "Διατήρηση ως PDF", + "offline_copies": "Αντίγραφα εκτός σύνδεσης" }, "highlights": { "no_highlights": "Δεν έχετε ακόμα επιλογές." @@ -119,6 +123,49 @@ "show": "Εμφάνιση αρχειοθετημένων σελιδοδεικτών σε ετικέτες και λίστες", "hide": "Απόκρυψη αρχειοθετημένων σελιδοδεικτών από ετικέτες και λίστες" } + }, + "reader_settings": { + "local_overrides_title": "Ενεργές ρυθμίσεις για συγκεκριμένη συσκευή", + "using_default": "Χρήση της προεπιλογής του πελάτη", + "clear_override_hint": "Εκκαθαρίστε την παράκαμψη συσκευής για να χρησιμοποιήσετε την καθολική ρύθμιση ({{value}})", + "font_size": "Μέγεθος γραμματοσειράς", + "font_family": "Οικογένεια γραμματοσειράς", + "preview_inline": "(προεπισκόπηση)", + "tooltip_preview": "Μη αποθηκευμένες αλλαγές προεπισκόπησης", + "save_to_all_devices": "Όλες οι συσκευές", + "tooltip_local": "Οι ρυθμίσεις της συσκευής διαφέρουν από τις καθολικές ρυθμίσεις", + "reset_preview": "Επαναφορά προεπισκόπησης", + "mono": "Monospace", + "line_height": "Ύψος γραμμής", + "tooltip_default": "Ρυθμίσεις ανάγνωσης", + "title": "Ρυθμίσεις ανάγνωσης", + "serif": "Serif", + "preview": "Προεπισκόπηση", + "not_set": "Δεν έχει οριστεί", + "clear_local_overrides": "Εκκαθάριση ρυθμίσεων συσκευής", + "preview_text": "Η γρήγορη καφέ αλεπού πηδάει πάνω από τον τεμπέλη σκύλο. Έτσι θα φαίνεται το κείμενό σου στην προβολή ανάγνωσης.", + "local_overrides_cleared": "Οι ρυθμίσεις για συγκεκριμένη συσκευή έχουν εκκαθαριστεί", + "local_overrides_description": "Αυτή η συσκευή έχει ρυθμίσεις ανάγνωσης που διαφέρουν από τις καθολικές προεπιλογές σου:", + "clear_defaults": "Εκκαθάριση όλων των προεπιλογών", + "description": "Ρύθμισε τις προεπιλεγμένες ρυθμίσεις κειμένου για την προβολή ανάγνωσης. Αυτές οι ρυθμίσεις συγχρονίζονται απ' όλες τις συσκευές σου.", + "defaults_cleared": "Οι προεπιλογές ανάγνωσης έχουν εκκαθαριστεί", + "save_hint": "Αποθηκεύστε τις ρυθμίσεις μόνο για αυτή τη συσκευή ή συγχρονίστε σε όλες τις συσκευές", + "save_as_default": "Αποθήκευση ως προεπιλογή", + "save_to_device": "Αυτή η συσκευή", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Μη αποθηκευμένες αλλαγές προεπισκόπησης; οι ρυθμίσεις της συσκευής διαφέρουν από τις καθολικές", + "adjust_hint": "Προσαρμόστε τις παραπάνω ρυθμίσεις για να κάνετε προεπισκόπηση των αλλαγών" + }, + "avatar": { + "upload": "Ανέβασε avatar", + "change": "Άλλαξε avatar", + "remove_confirm_title": "Να αφαιρεθεί το avatar;", + "updated": "Το avatar ανανεώθηκε", + "removed": "Το avatar αφαιρέθηκε", + "description": "Ανέβασε μια τετράγωνη εικόνα για να τη χρησιμοποιήσεις ως avatar.", + "remove_confirm_description": "Αυτό θα διαγράψει την τρέχουσα φωτογραφία προφίλ σου.", + "title": "Φωτογραφία Προφίλ", + "remove": "Αφαίρεσε avatar" } }, "ai": { @@ -132,7 +179,21 @@ "all_tagging": "Όλη η Ετικετοποίηση", "text_tagging": "Ετικετοποίηση Κειμένου", "image_tagging": "Ετικετοποίηση Εικόνων", - "summarization": "Περίληψη" + "summarization": "Περίληψη", + "tag_style": "Στυλ ετικέτας", + "auto_summarization_description": "Δημιουργήστε αυτόματα περιλήψεις για τους σελιδοδείκτες σας χρησιμοποιώντας AI.", + "auto_tagging": "Αυτόματη προσθήκη ετικετών", + "titlecase_spaces": "Κεφαλαία ανά λέξη με κενά", + "lowercase_underscores": "Μικρά με κάτω παύλες", + "inference_language": "Γλώσσα εξαγωγής συμπερασμάτων", + "titlecase_hyphens": "Κεφαλαία ανά λέξη με παύλες", + "lowercase_hyphens": "Μικρά με παύλες", + "lowercase_spaces": "Μικρά με κενά", + "inference_language_description": "Διάλεξε γλώσσα για τις ετικέτες και τις περιλήψεις που δημιουργούνται από την AI.", + "tag_style_description": "Διάλεξε πώς να μορφοποιηθούν οι αυτόματα δημιουργημένες ετικέτες σου.", + "auto_tagging_description": "Δημιουργήστε αυτόματα ετικέτες για τους σελιδοδείκτες σας χρησιμοποιώντας AI.", + "camelCase": "camelCase", + "auto_summarization": "Αυτόματη δημιουργία περιλήψεων" }, "feeds": { "rss_subscriptions": "Συνδρομές RSS", @@ -163,6 +224,7 @@ "import_export_bookmarks": "Εισαγωγή / Εξαγωγή Σελιδοδεικτών", "import_bookmarks_from_html_file": "Εισαγωγή Σελιδοδεικτών από αρχείο HTML", "import_bookmarks_from_pocket_export": "Εισαγωγή Σελιδοδεικτών από εξαγωγή Pocket", + "import_bookmarks_from_matter_export": "Εισαγωγή Σελιδοδεικτών από εξαγωγή Matter", "import_bookmarks_from_omnivore_export": "Εισαγωγή Σελιδοδεικτών από εξαγωγή Omnivore", "import_bookmarks_from_linkwarden_export": "Εισαγωγή Σελιδοδεικτών από εξαγωγή Linkwarden", "import_bookmarks_from_karakeep_export": "Εισαγωγή Σελιδοδεικτών από εξαγωγή Karakeep", @@ -680,7 +742,14 @@ "or": "Ή", "history": "Πρόσφατες αναζητήσεις", "title_contains": "Ο τίτλος περιέχει", - "title_does_not_contain": "Ο τίτλος δεν περιέχει" + "title_does_not_contain": "Ο τίτλος δεν περιέχει", + "is_broken_link": "Έχει κατεστραμμένο σύνδεσμο", + "tags": "Ετικέτες", + "no_suggestions": "Χωρίς προτάσεις", + "filters": "Φίλτρα", + "is_not_broken_link": "Έχει σύνδεσμο που λειτουργεί", + "lists": "Λίστες", + "feeds": "Ροές" }, "preview": { "view_original": "Προβολή Πρωτότυπου", @@ -689,7 +758,8 @@ "tabs": { "content": "Περιεχόμενο", "details": "Λεπτομέρειες" - } + }, + "archive_info": "Τα αρχεία ενδέχεται να μην αποδίδονται σωστά ενσωματωμένα, εάν απαιτούν Javascript. Για καλύτερα αποτελέσματα, <1>κατεβάστε το και ανοίξτε το στο πρόγραμμα περιήγησής σας</1>." }, "editor": { "quickly_focus": "Μπορείτε να εστιάσετε γρήγορα σε αυτό το πεδίο πατώντας ⌘ + E", @@ -763,7 +833,8 @@ "refetch": "Η επαναφόρτωση μπήκε στην ουρά!", "full_page_archive": "Η δημιουργία Πλήρους Αρχείου Σελίδας ενεργοποιήθηκε", "delete_from_list": "Ο σελιδοδείκτης διαγράφηκε από τη λίστα", - "clipboard_copied": "Ο σύνδεσμος προστέθηκε στο πρόχειρό σας!" + "clipboard_copied": "Ο σύνδεσμος προστέθηκε στο πρόχειρό σας!", + "preserve_pdf": "Η διατήρηση PDF έχει ενεργοποιηθεί" }, "lists": { "created": "Η λίστα δημιουργήθηκε!", diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 33c7d6e2..37212ede 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -1,5 +1,7 @@ { "common": { + "default": "Default", + "id": "ID", "url": "URL", "name": "Name", "email": "Email", @@ -8,6 +10,7 @@ "actions": "Actions", "created_at": "Created At", "updated_at": "Updated At", + "last_used": "Last Used", "key": "Key", "role": "Role", "type": "Type", @@ -25,6 +28,7 @@ "highlights": "Highlights", "source": "Source", "screenshot": "Screenshot", + "pdf": "Archived PDF", "video": "Video", "archive": "Archive", "home": "Home", @@ -69,7 +73,11 @@ "toggle_show_archived": "Show Archived", "refresh": "Refresh", "recrawl": "Recrawl", - "download_full_page_archive": "Download Full Page Archive", + "offline_copies": "Offline Copies", + "preserve_offline_archive": "Preserve Offline Archive", + "download_full_page_archive_file": "Download Archive File", + "preserve_as_pdf": "Preserve as PDF", + "download_pdf_file": "Download PDF File", "edit_tags": "Edit Tags", "edit_notes": "Edit Notes", "add_to_list": "Add to List", @@ -82,6 +90,7 @@ "remove_from_list": "Remove from List", "save": "Save", "add": "Add", + "remove": "Remove", "edit": "Edit", "confirm": "Confirm", "open_editor": "Open Editor", @@ -96,6 +105,9 @@ "regenerate": "Regenerate", "apply_all": "Apply All", "ignore": "Ignore", + "more": "More", + "replace_banner": "Replace Banner", + "add_banner": "Add Banner", "sort": { "title": "Sort", "relevant_first": "Most Relevant First", @@ -119,6 +131,17 @@ "confirm_new_password": "Confirm New Password", "options": "Options", "interface_lang": "Interface Language", + "avatar": { + "title": "Profile Photo", + "description": "Upload a square image to use as your avatar.", + "upload": "Upload avatar", + "change": "Change avatar", + "remove": "Remove avatar", + "remove_confirm_title": "Remove avatar?", + "remove_confirm_description": "This will clear your current profile photo.", + "updated": "Avatar updated", + "removed": "Avatar removed" + }, "user_settings": { "user_settings_updated": "User settings have been updated!", "bookmark_click_action": { @@ -131,6 +154,38 @@ "show": "Show archived bookmarks in tags and lists", "hide": "Hide archived bookmarks in tags and lists" } + }, + "reader_settings": { + "title": "Reader Settings", + "description": "Configure default text settings for the reader view. These settings sync across all your devices.", + "font_family": "Font Family", + "font_size": "Font Size", + "line_height": "Line Height", + "save_as_default": "Save as default", + "clear_defaults": "Clear all defaults", + "not_set": "Not set", + "using_default": "Using client default", + "preview": "Preview", + "preview_text": "The quick brown fox jumps over the lazy dog. This is how your reader view text will appear.", + "defaults_cleared": "Reader defaults have been cleared", + "local_overrides_title": "Device-specific settings active", + "local_overrides_description": "This device has reader settings that differ from your global defaults:", + "local_overrides_cleared": "Device-specific settings have been cleared", + "clear_local_overrides": "Clear device settings", + "serif": "Serif", + "sans": "Sans Serif", + "mono": "Monospace", + "tooltip_default": "Reading settings", + "tooltip_preview": "Unsaved preview changes", + "tooltip_local": "Device settings differ from global", + "tooltip_preview_and_local": "Unsaved preview changes; device settings differ from global", + "reset_preview": "Reset preview", + "save_to_device": "This device", + "save_to_all_devices": "All devices", + "save_hint": "Save settings for this device only or sync across all devices", + "adjust_hint": "Adjust settings above to preview changes", + "clear_override_hint": "Clear device override to use global setting ({{value}})", + "preview_inline": "(preview)" } }, "stats": { @@ -189,6 +244,10 @@ }, "ai": { "ai_settings": "AI Settings", + "auto_tagging": "Auto-tagging", + "auto_tagging_description": "Automatically generate tags for your bookmarks using AI.", + "auto_summarization": "Auto-summarization", + "auto_summarization_description": "Automatically generate summaries for your bookmarks using AI.", "tagging_rules": "Tagging Rules", "tagging_rule_description": "Prompts that you add here will be included as rules to the model during tag generation. You can view the final prompts in the prompt preview section.", "prompt_preview": "Prompt Preview", @@ -198,7 +257,22 @@ "all_tagging": "All Tagging", "text_tagging": "Text Tagging", "image_tagging": "Image Tagging", - "summarization": "Summarization" + "summarization": "Summarization", + "tag_style": "Tag Style", + "tag_style_description": "Choose how your auto-generated tags should be formatted.", + "lowercase_hyphens": "Lowercase with hyphens", + "lowercase_spaces": "Lowercase with spaces", + "lowercase_underscores": "Lowercase with underscores", + "titlecase_spaces": "Title case with spaces", + "titlecase_hyphens": "Title case with hyphens", + "camelCase": "camelCase", + "no_preference": "No preference", + "inference_language": "Inference Language", + "inference_language_description": "Choose language for AI-generated tags and summaries.", + "curated_tags": "Curated Tags", + "curated_tags_description": "Optionally restrict AI tagging to only use tags from this list. When no tags are selected, the AI generates tags freely.", + "curated_tags_updated": "Curated tags updated successfully!", + "curated_tags_update_failed": "Failed to update curated tags" }, "feeds": { "rss_subscriptions": "RSS Subscriptions", @@ -229,11 +303,13 @@ "import_export_bookmarks": "Import / Export Bookmarks", "import_bookmarks_from_html_file": "Import Bookmarks from HTML file", "import_bookmarks_from_pocket_export": "Import Bookmarks from Pocket export", + "import_bookmarks_from_matter_export": "Import Bookmarks from Matter export", "import_bookmarks_from_omnivore_export": "Import Bookmarks from Omnivore export", "import_bookmarks_from_linkwarden_export": "Import Bookmarks from Linkwarden export", "import_bookmarks_from_karakeep_export": "Import Bookmarks from Karakeep export", "import_bookmarks_from_tab_session_manager_export": "Import Bookmarks from Tab Session Manager", "import_bookmarks_from_mymind_export": "Import Bookmarks from mymind export", + "import_bookmarks_from_instapaper_export": "Import Bookmarks from Instapaper export", "export_links_and_notes": "Export Links and Notes", "imported_bookmarks": "Imported Bookmarks" }, @@ -285,6 +361,9 @@ "conditions_types": { "always": "Always", "url_contains": "URL Contains", + "url_does_not_contain": "URL Does Not Contain", + "title_contains": "Title Contains", + "title_does_not_contain": "Title Does Not Contain", "imported_from_feed": "Imported From Feed", "bookmark_type_is": "Bookmark Type Is", "has_tag": "Has Tag", @@ -342,11 +421,12 @@ "created_at": "Created {{time}}", "progress": "Progress", "status": { + "staging": "Staging", "pending": "Pending", - "in_progress": "In progress", + "running": "Running", + "paused": "Paused", "completed": "Completed", - "failed": "Failed", - "processing": "Processing" + "failed": "Failed" }, "badges": { "pending": "{{count}} pending", @@ -358,7 +438,33 @@ "view_list": "View List", "delete_dialog_title": "Delete Import Session", "delete_dialog_description": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone. The bookmarks themselves will not be deleted.", - "delete_session": "Delete Session" + "delete_session": "Delete Session", + "pause_session": "Pause", + "resume_session": "Resume", + "view_details": "View Details", + "detail": { + "page_title": "Import Session Details", + "back_to_import": "Back to Import", + "filter_all": "All", + "filter_accepted": "Accepted", + "filter_rejected": "Rejected", + "filter_duplicates": "Duplicates", + "filter_pending": "Pending", + "table_title": "Title / URL", + "table_type": "Type", + "table_result": "Result", + "table_reason": "Reason", + "table_bookmark": "Bookmark", + "result_accepted": "Accepted", + "result_rejected": "Rejected", + "result_skipped_duplicate": "Duplicate", + "result_pending": "Pending", + "result_processing": "Processing", + "no_results": "No results found for this filter.", + "view_bookmark": "View Bookmark", + "load_more": "Load More", + "no_title": "No title" + } }, "backups": { "backups": "Backups", @@ -485,11 +591,14 @@ } }, "actions": { + "recrawl_pending_links_only": "Recrawl Pending Links Only", "recrawl_failed_links_only": "Recrawl Failed Links Only", "recrawl_all_links": "Recrawl All Links", "without_inference": "Without Inference", + "regenerate_ai_tags_for_pending_bookmarks_only": "Regenerate AI Tags for Pending Bookmarks Only", "regenerate_ai_tags_for_failed_bookmarks_only": "Regenerate AI Tags for Failed Bookmarks Only", "regenerate_ai_tags_for_all_bookmarks": "Regenerate AI Tags for All Bookmarks", + "regenerate_ai_summaries_for_pending_bookmarks_only": "Regenerate AI Summaries for Pending Bookmarks Only", "regenerate_ai_summaries_for_failed_bookmarks_only": "Regenerate AI Summaries for Failed Bookmarks Only", "regenerate_ai_summaries_for_all_bookmarks": "Regenerate AI Summaries for All Bookmarks", "reindex_all_bookmarks": "Reindex All Bookmarks", @@ -510,11 +619,50 @@ "local_user": "Local User", "confirm_password": "Confirm Password", "unlimited": "Unlimited" + }, + "admin_tools": { + "admin_tools": "Admin Tools", + "bookmark_debugger": "Bookmark Debugger", + "bookmark_id": "Bookmark ID", + "bookmark_id_placeholder": "Enter bookmark ID", + "lookup": "Lookup", + "debug_info": "Debug Information", + "basic_info": "Basic Information", + "status": "Status", + "content": "Content", + "html_preview": "HTML Preview (First 1000 chars)", + "summary": "Summary", + "url": "URL", + "source_url": "Source URL", + "asset_type": "Asset Type", + "file_name": "File Name", + "owner_user_id": "Owner User ID", + "tagging_status": "Tagging Status", + "summarization_status": "Summarization Status", + "crawl_status": "Crawl Status", + "crawl_status_code": "HTTP Status Code", + "crawled_at": "Crawled At", + "recrawl": "Re-crawl", + "reindex": "Re-index", + "retag": "Re-tag", + "resummarize": "Re-summarize", + "bookmark_not_found": "Bookmark not found", + "action_success": "Action completed successfully", + "action_failed": "Action failed", + "recrawl_queued": "Re-crawl job has been queued", + "reindex_queued": "Re-index job has been queued", + "retag_queued": "Re-tag job has been queued", + "resummarize_queued": "Re-summarize job has been queued", + "view": "View", + "fetch_error": "Error fetching bookmark" } }, "options": { "dark_mode": "Dark Mode", - "light_mode": "Light Mode" + "light_mode": "Light Mode", + "apps_extensions": "Apps & Extensions", + "documentation": "Documentation", + "follow_us_on_x": "Follow us on X" }, "lists": { "all_lists": "All Lists", @@ -620,6 +768,8 @@ "create_tag_description": "Create a new tag without attaching it to any bookmark", "tag_name": "Tag Name", "enter_tag_name": "Enter tag name", + "search_placeholder": "Search tags...", + "search_or_create_placeholder": "Search or create tags...", "no_custom_tags": "No custom tags yet", "no_ai_tags": "No AI tags yet", "no_unused_tags": "You don't have any unused tags", @@ -662,6 +812,8 @@ "type_is_not": "Type is not", "is_from_feed": "Is from RSS Feed", "is_not_from_feed": "Is not from RSS Feed", + "is_from_source": "Source is", + "is_not_from_source": "Source is not", "is_broken_link": "Has Broken Link", "is_not_broken_link": "Has Working Link", "and": "And", @@ -677,6 +829,9 @@ "view_original": "View Original", "cached_content": "Cached Content", "reader_view": "Reader View", + "archive_info": "Archives may not render correctly inline if they require Javascript. For best results, <1>download it and open in your browser</1>.", + "fetch_error_title": "Content Unavailable", + "fetch_error_description": "We couldn't fetch the content for this link. The page may be protected, require authentication, or be temporarily unavailable.", "tabs": { "content": "Content", "details": "Details" @@ -752,8 +907,11 @@ "deleted": "The bookmark has been deleted!", "refetch": "Re-fetch has been enqueued!", "full_page_archive": "Full Page Archive creation has been triggered", + "preserve_pdf": "PDF preservation has been triggered", "delete_from_list": "The bookmark has been deleted from the list", - "clipboard_copied": "Link has been added to your clipboard!" + "clipboard_copied": "Link has been added to your clipboard!", + "update_banner": "Banner has been updated!", + "uploading_banner": "Uploading banner..." }, "lists": { "created": "List has been created!", @@ -798,5 +956,54 @@ "no_release_notes": "No release notes were published for this version.", "release_notes_synced": "Release notes are synced from GitHub.", "view_on_github": "View on GitHub" + }, + "wrapped": { + "title": "Your {{year}} Wrapped", + "subtitle": "A Year in Karakeep", + "banner": { + "title": "Your 2025 Wrapped is ready!", + "description": "See your year in bookmarks", + "view_now": "View Now" + }, + "button": "2025 Wrapped", + "loading": "Loading your Wrapped...", + "failed_to_load": "Failed to load your Wrapped stats", + "sections": { + "total_saves": { + "prefix": "You saved", + "suffix": "items this year", + "suffix_singular": "item this year" + }, + "first_bookmark": { + "title": "Your Journey Started", + "description": "First save of {{year}}:" + }, + "top_domains": "Your Top Sites", + "top_tags": "Your Top Tags", + "monthly_activity": "Your Year in Saves", + "most_active_day": "Your Most Active Day", + "peak_times": { + "title": "When You Save", + "peak_hour": "Peak Hour", + "peak_day": "Peak Day" + }, + "how_you_save": "How You Save", + "what_you_saved": "What You Saved", + "summary": { + "favorites": "Favorites", + "tags_created": "Tags Created", + "highlights": "Highlights" + }, + "types": { + "links": "Links", + "notes": "Notes", + "assets": "Assets" + } + }, + "footer": "Made with Karakeep", + "share": "Share", + "download": "Download", + "close": "Close", + "generating": "Generating..." } } diff --git a/apps/web/lib/i18n/locales/en_US/translation.json b/apps/web/lib/i18n/locales/en_US/translation.json index 12af64e8..9e98b09e 100644 --- a/apps/web/lib/i18n/locales/en_US/translation.json +++ b/apps/web/lib/i18n/locales/en_US/translation.json @@ -25,6 +25,7 @@ "admin": "Admin" }, "screenshot": "Screenshot", + "pdf": "Archived PDF", "video": "Video", "archive": "Archive", "home": "Home", @@ -39,7 +40,8 @@ }, "quota": "Quota", "bookmarks": "Bookmarks", - "storage": "Storage" + "storage": "Storage", + "default": "Default" }, "layouts": { "masonry": "Masonry", @@ -62,7 +64,9 @@ "delete": "Delete", "refresh": "Refresh", "recrawl": "Recrawl", + "offline_copies": "Offline Copies", "download_full_page_archive": "Download Full Page Archive", + "preserve_as_pdf": "Preserve as PDF", "edit_tags": "Edit Tags", "add_to_list": "Add to List", "select_all": "Select All", @@ -200,6 +204,17 @@ "confirm_new_password": "Confirm New Password", "options": "Options", "interface_lang": "Interface Language", + "avatar": { + "title": "Profile Photo", + "description": "Upload a square image to use as your avatar.", + "upload": "Upload avatar", + "change": "Change avatar", + "remove": "Remove avatar", + "remove_confirm_title": "Remove avatar?", + "remove_confirm_description": "This will clear your current profile photo.", + "updated": "Avatar updated", + "removed": "Avatar removed" + }, "user_settings": { "user_settings_updated": "User settings have been updated!", "bookmark_click_action": { @@ -212,6 +227,38 @@ "show": "Show archived bookmarks in tags and lists", "hide": "Hide archived bookmarks in tags and lists" } + }, + "reader_settings": { + "local_overrides_title": "Device-specific settings active", + "using_default": "Using client default", + "clear_override_hint": "Clear device override to use global setting ({{value}})", + "font_size": "Font Size", + "font_family": "Font Family", + "preview_inline": "(preview)", + "tooltip_preview": "Unsaved preview changes", + "save_to_all_devices": "All devices", + "tooltip_local": "Device settings differ from global", + "reset_preview": "Reset preview", + "mono": "Monospace", + "line_height": "Line Height", + "tooltip_default": "Reading settings", + "title": "Reader Settings", + "serif": "Serif", + "preview": "Preview", + "not_set": "Not set", + "clear_local_overrides": "Clear device settings", + "preview_text": "The quick brown fox jumps over the lazy dog. This is how your reader view text will appear.", + "local_overrides_cleared": "Device-specific settings have been cleared", + "local_overrides_description": "This device has reader settings that differ from your global defaults:", + "clear_defaults": "Clear all defaults", + "description": "Configure default text settings for the reader view. These settings sync across all your devices.", + "defaults_cleared": "Reader defaults have been cleared", + "save_hint": "Save settings for this device only or sync across all devices", + "save_as_default": "Save as default", + "save_to_device": "This device", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Unsaved preview changes; device settings differ from global", + "adjust_hint": "Adjust settings above to preview changes" } }, "ai": { @@ -225,7 +272,21 @@ "all_tagging": "All Tagging", "text_tagging": "Text Tagging", "image_tagging": "Image Tagging", - "summarization": "Summarization" + "summarization": "Summarization", + "tag_style": "Tag Style", + "auto_summarization_description": "Automatically generate summaries for your bookmarks using AI.", + "auto_tagging": "Auto-tagging", + "titlecase_spaces": "Title case with spaces", + "lowercase_underscores": "Lowercase with underscores", + "inference_language": "Inference Language", + "titlecase_hyphens": "Title case with hyphens", + "lowercase_hyphens": "Lowercase with hyphens", + "lowercase_spaces": "Lowercase with spaces", + "inference_language_description": "Choose language for AI-generated tags and summaries.", + "tag_style_description": "Choose how your auto-generated tags should be formatted.", + "auto_tagging_description": "Automatically generate tags for your bookmarks using AI.", + "camelCase": "camelCase", + "auto_summarization": "Auto-summarization" }, "feeds": { "rss_subscriptions": "RSS Subscriptions", @@ -257,6 +318,7 @@ "import_export_bookmarks": "Import / Export Bookmarks", "import_bookmarks_from_html_file": "Import Bookmarks from HTML file", "import_bookmarks_from_pocket_export": "Import Bookmarks from Pocket export", + "import_bookmarks_from_matter_export": "Import Bookmarks from Matter export", "import_bookmarks_from_omnivore_export": "Import Bookmarks from Omnivore export", "import_bookmarks_from_linkwarden_export": "Import Bookmarks from Linkwarden export", "import_bookmarks_from_karakeep_export": "Import Bookmarks from Karakeep export", @@ -754,7 +816,10 @@ "filters": "Filters", "tags": "Tags", "lists": "Lists", - "no_suggestions": "No suggestions" + "no_suggestions": "No suggestions", + "is_broken_link": "Has Broken Link", + "is_not_broken_link": "Has Working Link", + "feeds": "Feeds" }, "preview": { "view_original": "View Original", @@ -763,13 +828,15 @@ "tabs": { "content": "Content", "details": "Details" - } + }, + "archive_info": "Archives may not render correctly inline if they require Javascript. For best results, <1>download it and open in your browser</1>." }, "toasts": { "bookmarks": { "deleted": "The bookmark has been deleted!", "refetch": "Re-fetch has been enqueued!", "full_page_archive": "Full Page Archive creation has been triggered", + "preserve_pdf": "PDF preservation has been triggered", "delete_from_list": "The bookmark has been deleted from the list", "clipboard_copied": "Link has been added to your clipboard!", "updated": "The bookmark has been updated!" diff --git a/apps/web/lib/i18n/locales/es/translation.json b/apps/web/lib/i18n/locales/es/translation.json index 6b2b78a4..6dd2aa78 100644 --- a/apps/web/lib/i18n/locales/es/translation.json +++ b/apps/web/lib/i18n/locales/es/translation.json @@ -39,7 +39,9 @@ "description": "Descripción", "quota": "Cuota", "bookmarks": "Marcadores", - "storage": "Almacenamiento" + "storage": "Almacenamiento", + "pdf": "PDF archivado", + "default": "Predeterminado" }, "settings": { "info": { @@ -63,6 +65,49 @@ "show": "Mostrar marcadores archivados en etiquetas y listas", "hide": "Ocultar marcadores archivados en etiquetas y listas" } + }, + "reader_settings": { + "local_overrides_title": "Ajustes específicos del dispositivo activos", + "using_default": "Usando el valor predeterminado del cliente", + "clear_override_hint": "Borra la configuración específica del dispositivo para usar la configuración global ({{value}})", + "font_size": "Tamaño de fuente", + "font_family": "Familia de fuentes", + "preview_inline": "(vista previa)", + "tooltip_preview": "Cambios de la vista previa sin guardar", + "save_to_all_devices": "Todos los dispositivos", + "tooltip_local": "Los ajustes del dispositivo difieren de los globales", + "reset_preview": "Restablecer vista previa", + "mono": "Monoespacio", + "line_height": "Altura de la línea", + "tooltip_default": "Ajustes de lectura", + "title": "Ajustes del lector", + "serif": "Con gracias", + "preview": "Vista previa", + "not_set": "No configurado", + "clear_local_overrides": "Borrar la configuración del dispositivo", + "preview_text": "El veloz murciélago hindú comía feliz cardillo y kiwi. Así es como aparecerá el texto en tu vista de lectura.", + "local_overrides_cleared": "Se han borrado los ajustes específicos del dispositivo", + "local_overrides_description": "Este dispositivo tiene ajustes de lector que difieren de los valores predeterminados globales:", + "clear_defaults": "Borrar todos los valores predeterminados", + "description": "Configura los ajustes de texto predeterminados para la vista de lectura. Estos ajustes se sincronizan en todos tus dispositivos.", + "defaults_cleared": "Se han borrado los valores predeterminados del lector", + "save_hint": "Guarda la configuración sólo para este dispositivo o sincronízala en todos los dispositivos", + "save_as_default": "Guardar como predeterminado", + "save_to_device": "Este dispositivo", + "sans": "Sin gracias", + "tooltip_preview_and_local": "Cambios de la vista previa sin guardar; los ajustes del dispositivo difieren de los globales", + "adjust_hint": "Just the tip: ajusta la configuración de arriba para previsualizar los cambios" + }, + "avatar": { + "upload": "Subir avatar", + "change": "Cambiar avatar", + "remove_confirm_title": "¿Eliminar avatar?", + "updated": "Avatar actualizado", + "removed": "Avatar eliminado", + "description": "Sube una imagen cuadrada para usarla como tu avatar.", + "remove_confirm_description": "Esto borrará tu foto de perfil actual.", + "title": "Foto de perfil", + "remove": "Eliminar avatar" } }, "back_to_app": "Volver a la aplicación", @@ -77,7 +122,21 @@ "summarization_prompt": "Indicación de resumen", "all_tagging": "Todo el etiquetado", "text_tagging": "Etiquetado de texto", - "image_tagging": "Etiquetado de imágenes" + "image_tagging": "Etiquetado de imágenes", + "tag_style": "Estilo de etiqueta", + "auto_summarization_description": "Genera resúmenes automáticamente para tus marcadores usando IA.", + "auto_tagging": "Etiquetado automático", + "titlecase_spaces": "Mayúsculas y minúsculas con espacios", + "lowercase_underscores": "Minúsculas con guiones bajos", + "inference_language": "Idioma de Inferencia", + "titlecase_hyphens": "Mayúsculas y minúsculas con guiones", + "lowercase_hyphens": "Minúsculas con guiones", + "lowercase_spaces": "Minúsculas con espacios", + "inference_language_description": "Elige el idioma para las etiquetas y los resúmenes generados por la IA.", + "tag_style_description": "Elige cómo quieres que se formateen las etiquetas que se generan automáticamente.", + "auto_tagging_description": "Genera etiquetas automáticamente para tus marcadores usando IA.", + "camelCase": "camelCase", + "auto_summarization": "Resumen automático" }, "user_settings": "Ajustes de usuario", "feeds": { @@ -90,6 +149,7 @@ "import_export": "Importar / Exportar", "import_export_bookmarks": "Importar / Exportar marcadores", "import_bookmarks_from_pocket_export": "Importar marcadores desde exportación de Pocket", + "import_bookmarks_from_matter_export": "Importar marcadores desde exportación de Matter", "export_links_and_notes": "Exportar links y notas", "imported_bookmarks": "Marcadores importados", "import_bookmarks_from_karakeep_export": "Importar marcadores desde exportación de Karakeep", @@ -387,7 +447,9 @@ "confirm": "Confirmar", "regenerate": "Regenerar", "load_more": "Cargar más", - "edit_notes": "Editar notas" + "edit_notes": "Editar notas", + "preserve_as_pdf": "Conservar como PDF", + "offline_copies": "Copias sin conexión" }, "layouts": { "compact": "Compacto", @@ -646,7 +708,8 @@ "tabs": { "content": "Contenido", "details": "Detalles" - } + }, + "archive_info": "Es posible que los archivos no se rendericen correctamente en línea si requieren Javascript. Para obtener mejores resultados, <1>descárgalo y ábrelo en tu navegador</1>." }, "editor": { "multiple_urls_dialog_title": "¿Importar URLs como marcadores independientes?", @@ -714,7 +777,8 @@ "deleted": "¡El marcador se ha eliminado!", "full_page_archive": "Se ha pedido un Archivo de Página Completa", "delete_from_list": "El marcador se ha borrado de la lista", - "clipboard_copied": "¡El enlace se ha copiado en tu portapapeles!" + "clipboard_copied": "¡El enlace se ha copiado en tu portapapeles!", + "preserve_pdf": "Se ha activado la preservación en PDF" }, "lists": { "created": "¡Enlace creado correctamente!", @@ -775,7 +839,14 @@ "year_s_ago": " Hace año(s)", "history": "Búsquedas recientes", "title_contains": "El título contiene", - "title_does_not_contain": "El título no contiene" + "title_does_not_contain": "El título no contiene", + "is_broken_link": "Tiene enlace roto", + "tags": "Etiquetas", + "no_suggestions": "Sin sugerencias", + "filters": "Filtros", + "is_not_broken_link": "Tiene enlace que funciona", + "lists": "Listas", + "feeds": "Feeds" }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/fa/translation.json b/apps/web/lib/i18n/locales/fa/translation.json index 5136e368..6bd97788 100644 --- a/apps/web/lib/i18n/locales/fa/translation.json +++ b/apps/web/lib/i18n/locales/fa/translation.json @@ -39,7 +39,9 @@ "text": "متن", "media": "رسانه" }, - "quota": "سهمیه" + "quota": "سهمیه", + "pdf": "پیدیاف بایگانیشده", + "default": "پیشفرض" }, "layouts": { "grid": "شبکهای", @@ -90,7 +92,9 @@ "oldest_first": "قدیمیترینها ابتدا" }, "load_more": "بارگذاری بیشتر", - "edit_notes": "ویرایش یادداشتها" + "edit_notes": "ویرایش یادداشتها", + "preserve_as_pdf": "به عنوان پیدیاف نگهداریاش کن", + "offline_copies": "نسخههای آفلاین" }, "settings": { "stats": { @@ -169,6 +173,49 @@ "show": "نمایش نشانکهای بایگانیشده در برچسبها و فهرستها", "hide": "مخفیکردن نشانکهای بایگانیشده در برچسبها و فهرستها" } + }, + "reader_settings": { + "local_overrides_title": "تنظیمات مختص دستگاه فعال هستن", + "using_default": "در حال استفاده از پیشفرض مشتری", + "clear_override_hint": "پاک کردن لغو دستگاه برای استفاده از تنظیمات سراسری ({{value}})", + "font_size": "اندازه فونت", + "font_family": "خانواده فونت", + "preview_inline": "(پیشنمایش)", + "tooltip_preview": "تغییرات پیش نمایش ذخیره نشده", + "save_to_all_devices": "همه دستگاهها", + "tooltip_local": "تنظیمات دستگاه با تنظیمات سراسری فرق داره", + "reset_preview": "بازنشانی پیشنمایش", + "mono": "تکفاصله", + "line_height": "ارتفاع خط", + "tooltip_default": "تنظیمات خواندن", + "title": "تنظیمات خواننده", + "serif": "سریدار", + "preview": "پیشنمایش", + "not_set": "تنظیم نشده", + "clear_local_overrides": "تنظیمات دستگاه رو پاک کن", + "preview_text": "روباه قهوهای زرنگ از روی سگ تنبل میپره. متن قسمت خواننده اینجوری نمایش داده میشه.", + "local_overrides_cleared": "تنظیمات دستگاه پاک شدن", + "local_overrides_description": "این دستگاه تنظیمات خوانندهای داره که با تنظیمات پیشفرض کلیت فرق دارن:", + "clear_defaults": "همه پیشفرضها رو پاک کن", + "description": "تنظیم متن پیشفرض برای بخش خواننده رو پیکربندی کن. این تنظیمات با بقیه دستگاههات هماهنگ میشه.", + "defaults_cleared": "پیشفرضهای قسمت خواننده پاک شدن", + "save_hint": "ذخیره تنظیمات فقط برای این دستگاه یا همگام سازی بین همه دستگاه ها", + "save_as_default": "به عنوان پیشفرض ذخیره کن", + "save_to_device": "این دستگاه", + "sans": "بدون سری", + "tooltip_preview_and_local": "تغییرات پیشنمایش ذخیره نشده؛ تنظیمات دستگاه با تنظیمات کلی فرق داره", + "adjust_hint": "برای پیش نمایش تغییرات، تنظیمات بالا را تنظیم کنید" + }, + "avatar": { + "upload": "بارگذاری آواتار", + "change": "تغییر آواتار", + "remove_confirm_title": "آواتار حذف بشه؟", + "updated": "آواتار بهروز شد", + "removed": "آواتار حذف شد", + "description": "یه عکس مربع بارگذاری کن تا به عنوان آواتارت استفاده بشه.", + "remove_confirm_description": "این کار عکس پروفایل فعلیتو پاک میکنه.", + "title": "عکس پروفایل", + "remove": "حذف آواتار" } }, "user_settings": "تنظیمات کاربر", @@ -183,7 +230,21 @@ "text_prompt": "پرامپت متنی", "text_tagging": "برچسبگذاری متن", "summarization_prompt": "پرامپت خلاصهسازی", - "summarization": "خلاصهسازی" + "summarization": "خلاصهسازی", + "tag_style": "استایل برچسب", + "auto_summarization_description": "بهطور خودکار با استفاده از هوش مصنوعی برای نشانکهایت خلاصه تولید کن.", + "auto_tagging": "برچسبگذاری خودکار", + "titlecase_spaces": "حالت عنوان با فاصلهها", + "lowercase_underscores": "حروف کوچک با زیرخطها", + "inference_language": "زبان استنباطی", + "titlecase_hyphens": "حالت عنوان با خط تیره", + "lowercase_hyphens": "حروف کوچک با خط تیره", + "lowercase_spaces": "حروف کوچک با فاصلهها", + "inference_language_description": "زبانی را برای برچسبها و خلاصههای تولید شده توسط هوش مصنوعی انتخاب کنید.", + "tag_style_description": "انتخاب کنید که برچسبهای تولیدشده خودکار شما چگونه قالببندی شوند.", + "auto_tagging_description": "بهطور خودکار با استفاده از هوش مصنوعی برای نشانکهایت برچسب تولید کن.", + "camelCase": "camelCase", + "auto_summarization": "خلاصهسازی خودکار" }, "feeds": { "feed_enabled": "خوراک RSS فعال شد", @@ -214,6 +275,7 @@ "import_bookmarks_from_html_file": "درونریزی نشانکها از فایل HTML", "import_export_bookmarks": "درونریزی / برونبری نشانکها", "import_bookmarks_from_pocket_export": "درونریزی نشانکها از خروجی Pocket", + "import_bookmarks_from_matter_export": "درونریزی نشانکها از خروجی Matter", "import_bookmarks_from_omnivore_export": "درونریزی نشانکها از خروجی Omnivore", "import_bookmarks_from_linkwarden_export": "درونریزی نشانکها از خروجی Linkwarden", "import_bookmarks_from_tab_session_manager_export": "درونریزی نشانکها از Tab Session Manager", @@ -656,7 +718,14 @@ "is_not_from_feed": "از فید RSS نیست", "and": "و", "or": "یا", - "history": "جستجوهای اخیر" + "history": "جستجوهای اخیر", + "is_broken_link": "لینک خراب دارد", + "tags": "برچسبها", + "no_suggestions": "بدون پیشنهادها", + "filters": "فیلترها", + "is_not_broken_link": "لینک درست دارد", + "lists": "فهرستها", + "feeds": "فیدها" }, "preview": { "view_original": "مشاهدهی اصلی", @@ -665,7 +734,8 @@ "tabs": { "content": "محتوا", "details": "جزئیات" - } + }, + "archive_info": "ممکنه آرشیوها اگه نیاز به جاوااسکریپت داشته باشن، درست نشون داده نشن. برای بهترین نتیجه، <1>اونو دانلود و تو مرورگر بازش کن</1>." }, "editor": { "quickly_focus": "با فشردن ⌘ + E میتوانید به سرعت روی این فیلد تمرکز کنید", @@ -739,7 +809,8 @@ "refetch": "دوباره واکشی به صف اضافه شد!", "full_page_archive": "ایجاد بایگانی کامل صفحه آغاز شد", "delete_from_list": "نشانک از فهرست حذف شد", - "clipboard_copied": "لینک به کلیپبورد شما اضافه شد!" + "clipboard_copied": "لینک به کلیپبورد شما اضافه شد!", + "preserve_pdf": "نگهداری پیدیاف فعال شدهاست" }, "lists": { "created": "فهرست درست شد!", diff --git a/apps/web/lib/i18n/locales/fi/translation.json b/apps/web/lib/i18n/locales/fi/translation.json index 33717a24..06660ccd 100644 --- a/apps/web/lib/i18n/locales/fi/translation.json +++ b/apps/web/lib/i18n/locales/fi/translation.json @@ -39,7 +39,9 @@ "url": "URL", "quota": "Kiintiö", "bookmarks": "Kirjanmerkit", - "storage": "Tallennustila" + "storage": "Tallennustila", + "pdf": "Arkistoitu PDF", + "default": "Oletus" }, "layouts": { "masonry": "Tiililadonta", @@ -90,7 +92,9 @@ "confirm": "Vahvista", "regenerate": "Uudista", "load_more": "Lataa lisää", - "edit_notes": "Muokkaa muistiinpanoja" + "edit_notes": "Muokkaa muistiinpanoja", + "preserve_as_pdf": "Säilytä PDF-muodossa", + "offline_copies": "Offline-kopiot" }, "highlights": { "no_highlights": "Sulla ei oo vielä yhtään korostusta." @@ -119,6 +123,49 @@ "show": "Näytä arkistoidut kirjanmerkit tunnisteissa ja listoissa", "hide": "Piilota arkistoidut kirjanmerkit tunnisteissa ja listoissa" } + }, + "reader_settings": { + "local_overrides_title": "Laitteen omat asetukset ovat käytössä", + "using_default": "Käytetään asiakkaan oletusarvoa", + "clear_override_hint": "Tyhjennä laitteen ohitus, jotta voit käyttää globaalia asetusta ({{value}})", + "font_size": "Fonttikoko", + "font_family": "Fonttiperhe", + "preview_inline": "(esikatselu)", + "tooltip_preview": "Tallentamattomia esikatselun muutoksia", + "save_to_all_devices": "Kaikissa laitteissa", + "tooltip_local": "Laitteen asetukset poikkeavat globaaleista", + "reset_preview": "Nollaa esikatselu", + "mono": "Monospace", + "line_height": "Rivikorkeus", + "tooltip_default": "Lukemisen asetukset", + "title": "Lukijan asetukset", + "serif": "Serif", + "preview": "Esikatselu", + "not_set": "Ei asetettu", + "clear_local_overrides": "Tyhjennä laitteen asetukset", + "preview_text": "The quick brown fox jumps over the lazy dog. Näin lukijanäkymän tekstisi näkyy.", + "local_overrides_cleared": "Laitteen omat asetukset on tyhjennetty", + "local_overrides_description": "Tässä laitteessa on lukija-asetukset, jotka poikkeavat yleisistä oletusarvoistasi:", + "clear_defaults": "Tyhjennä kaikki oletusarvot", + "description": "Määritä lukijanäkymän oletustekstiasetukset. Nämä asetukset synkronoidaan kaikkien laitteidesi välillä.", + "defaults_cleared": "Lukijan oletusarvot on tyhjennetty", + "save_hint": "Tallenna asetukset vain tälle laitteelle tai synkronoi kaikkiin laitteisiin", + "save_as_default": "Tallenna oletusarvoksi", + "save_to_device": "Tällä laitteella", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Tallentamattomia esikatselun muutoksia; laitteen asetukset poikkeavat globaaleista", + "adjust_hint": "Säädä yllä olevia asetuksia, jotta näet muutokset" + }, + "avatar": { + "upload": "Lataa avatar", + "change": "Vaihda avatar", + "remove_confirm_title": "Poistetaanko avatar?", + "updated": "Avatar päivitetty", + "removed": "Avatar poistettu", + "description": "Lataa neliön muotoinen kuva, jota käytetään avatarinasi.", + "remove_confirm_description": "Tämä poistaa nykyisen profiilikuvasi.", + "title": "Profiilikuva", + "remove": "Poista avatar" } }, "ai": { @@ -132,7 +179,21 @@ "all_tagging": "Kaikki tägääminen", "text_tagging": "Tekstin merkitseminen", "image_tagging": "Kuvien merkitseminen", - "summarization": "Yhteenvedon luonti" + "summarization": "Yhteenvedon luonti", + "tag_style": "Tagityyli", + "auto_summarization_description": "Luo kirjanmerkeillesi automaattisesti tiivistelmiä tekoälyn avulla.", + "auto_tagging": "Automaattinen tägääminen", + "titlecase_spaces": "Isot alkukirjaimet ja välilyönnit", + "lowercase_underscores": "Pienet kirjaimet ja alleviivat", + "inference_language": "Päättelykieli", + "titlecase_hyphens": "Isot alkukirjaimet ja yhdysmerkit", + "lowercase_hyphens": "Pienet kirjaimet ja yhdysmerkit", + "lowercase_spaces": "Pienet kirjaimet ja välilyönnit", + "inference_language_description": "Valitse kieli AI-generoiduille tunnisteille ja yhteenvedoille.", + "tag_style_description": "Valitse, miten automaattisesti luotujen tunnisteiden muoto tulisi olla.", + "auto_tagging_description": "Luo kirjanmerkeillesi automaattisesti tägejä tekoälyn avulla.", + "camelCase": "camelCase", + "auto_summarization": "Automaattinen tiivistys" }, "feeds": { "rss_subscriptions": "RSS-tilaukset", @@ -163,6 +224,7 @@ "import_export_bookmarks": "Kirjanmerkkien tuonti / vienti", "import_bookmarks_from_html_file": "Tuo kirjanmerkkejä HTML-tiedostosta", "import_bookmarks_from_pocket_export": "Tuo kirjanmerkit Pocket-viennistä", + "import_bookmarks_from_matter_export": "Tuo kirjanmerkit Matter-viennistä", "import_bookmarks_from_omnivore_export": "Tuo kirjanmerkit Omnivore-viennistä", "import_bookmarks_from_linkwarden_export": "Tuo kirjanmerkit Linkwarden-viennistä", "import_bookmarks_from_hoarder_export": "Tuo kirjanmerkit Hoarder-viennistä", @@ -681,7 +743,14 @@ "year_s_ago": " Vuosi(a) sitten", "history": "Viimeaikaiset haut", "title_contains": "Otsikko sisältää", - "title_does_not_contain": "Otsikko ei sisällä" + "title_does_not_contain": "Otsikko ei sisällä", + "is_broken_link": "On rikkinäinen linkki", + "tags": "Tunnisteet", + "no_suggestions": "Ei ehdotuksia", + "filters": "Suodattimet", + "is_not_broken_link": "On toimiva linkki", + "lists": "Listat", + "feeds": "Syötteet" }, "preview": { "view_original": "Näytä alkuperäinen", @@ -690,7 +759,8 @@ "tabs": { "content": "Sisältö", "details": "Tiedot" - } + }, + "archive_info": "Arkistot eivät välttämättä hahmotu oikein, jos ne vaativat Javascriptiä. Parhaan tuloksen saat, kun <1>lataat sen ja avaat sen selaimessasi</1>." }, "editor": { "quickly_focus": "Voit nopeasti kohdistaa tähän kenttään painamalla ⌘ + E", @@ -764,7 +834,8 @@ "refetch": "Uudelleennouto on jonossa!", "full_page_archive": "Koko sivun arkiston luonti on käynnistetty", "delete_from_list": "Kirjanmerkki on poistettu luettelosta", - "clipboard_copied": "Linkki on lisätty leikepöydälle!" + "clipboard_copied": "Linkki on lisätty leikepöydälle!", + "preserve_pdf": "PDF:nä säilytys on käynnistetty" }, "lists": { "created": "Lista on luotu!", diff --git a/apps/web/lib/i18n/locales/fr/translation.json b/apps/web/lib/i18n/locales/fr/translation.json index 3028d91d..94cb7b03 100644 --- a/apps/web/lib/i18n/locales/fr/translation.json +++ b/apps/web/lib/i18n/locales/fr/translation.json @@ -39,7 +39,9 @@ "summary": "Résumé", "quota": "Quota", "bookmarks": "Marque-pages", - "storage": "Stockage" + "storage": "Stockage", + "pdf": "PDF archivé", + "default": "Par défaut" }, "layouts": { "masonry": "Mosaïque", @@ -90,7 +92,9 @@ "confirm": "Confirmer", "regenerate": "Régénérer", "load_more": "En charger plus", - "edit_notes": "Modifier les notes" + "edit_notes": "Modifier les notes", + "preserve_as_pdf": "Conserver en PDF", + "offline_copies": "Copies hors ligne" }, "settings": { "back_to_app": "Retour à l'application", @@ -116,6 +120,49 @@ "open_external_url": "Ouvrir l’URL d’origine", "open_bookmark_details": "Ouvrir les détails du marque-page" } + }, + "reader_settings": { + "local_overrides_title": "Paramètres spécifiques à l’appareil actifs", + "using_default": "Utilisation des paramètres par défaut du client", + "clear_override_hint": "Effacer la substitution de l’appareil pour utiliser le paramètre général ({{value}})", + "font_size": "Taille de la police", + "font_family": "Famille de polices", + "preview_inline": "(aperçu)", + "tooltip_preview": "Modifications de l’aperçu non enregistrées", + "save_to_all_devices": "Tous les appareils", + "tooltip_local": "Les paramètres de l’appareil diffèrent des paramètres généraux", + "reset_preview": "Réinitialiser l’aperçu", + "mono": "Monospace", + "line_height": "Hauteur de ligne", + "tooltip_default": "Paramètres de lecture", + "title": "Paramètres du lecteur", + "serif": "Avec empattement", + "preview": "Aperçu", + "not_set": "Non défini", + "clear_local_overrides": "Effacer les paramètres de l’appareil", + "preview_text": "Le rapide renard brun saute par-dessus le chien paresseux. Voici comment apparaîtra le texte de votre affichage de lecteur.", + "local_overrides_cleared": "Les paramètres spécifiques à l’appareil ont été effacés", + "local_overrides_description": "Cet appareil a des paramètres de lecteur qui diffèrent de vos paramètres par défaut globaux :", + "clear_defaults": "Effacer toutes les valeurs par défaut", + "description": "Configurez les paramètres de texte par défaut pour l’affichage du lecteur. Ces paramètres sont synchronisés sur tous vos appareils.", + "defaults_cleared": "Les paramètres par défaut du lecteur ont été supprimés", + "save_hint": "Enregistrer les paramètres pour cet appareil uniquement ou synchroniser avec tous les appareils", + "save_as_default": "Enregistrer comme valeurs par défaut", + "save_to_device": "Cet appareil", + "sans": "Sans empattement", + "tooltip_preview_and_local": "Modifications de l’aperçu non enregistrées ; les paramètres de l’appareil diffèrent des paramètres généraux", + "adjust_hint": "Ajustez les paramètres ci-dessus pour prévisualiser les modifications" + }, + "avatar": { + "upload": "Téléverser un avatar", + "change": "Changer d’avatar", + "remove_confirm_title": "Supprimer l’avatar ?", + "updated": "Avatar mis à jour", + "removed": "Avatar supprimé", + "description": "Téléversez une image carrée à utiliser comme avatar.", + "remove_confirm_description": "Cela supprimera votre photo de profil actuelle.", + "title": "Photo de profil", + "remove": "Supprimer l’avatar" } }, "ai": { @@ -129,7 +176,21 @@ "all_tagging": "Tout le tagging", "text_tagging": "Balises de texte", "image_tagging": "Marquage d'image", - "summarization": "Résumer" + "summarization": "Résumer", + "tag_style": "Style des balises", + "auto_summarization_description": "Générez automatiquement des résumés pour vos favoris à l’aide de l’IA.", + "auto_tagging": "Attribution automatique de balises", + "titlecase_spaces": "Majuscule en début de mot avec espaces", + "lowercase_underscores": "Minuscules avec traits de soulignement", + "inference_language": "Langue d’inférence", + "titlecase_hyphens": "Majuscule en début de mot avec tirets", + "lowercase_hyphens": "Minuscules avec tirets", + "lowercase_spaces": "Minuscules avec espaces", + "inference_language_description": "Choisissez la langue pour les balises et les résumés générés par l’IA.", + "tag_style_description": "Choisissez le format de vos balises générées automatiquement.", + "auto_tagging_description": "Générez automatiquement des balises pour vos favoris à l’aide de l’IA.", + "camelCase": "camelCase", + "auto_summarization": "Résumés automatiques" }, "feeds": { "rss_subscriptions": "Abonnements RSS", @@ -142,6 +203,7 @@ "import_export_bookmarks": "Importer / Exporter des favoris", "import_bookmarks_from_html_file": "Importer des favoris depuis un fichier HTML", "import_bookmarks_from_pocket_export": "Importer des favoris depuis une exportation Pocket", + "import_bookmarks_from_matter_export": "Importer des favoris depuis une exportation Matter", "import_bookmarks_from_omnivore_export": "Importer des favoris depuis une exportation Omnivore", "import_bookmarks_from_karakeep_export": "Importer des favoris depuis une exportation Karakeep", "export_links_and_notes": "Exporter les liens et les notes", @@ -646,7 +708,8 @@ "tabs": { "details": "Détails", "content": "Contenu" - } + }, + "archive_info": "Les archives peuvent ne pas s'afficher correctement en ligne si elles nécessitent Javascript. Pour de meilleurs résultats, <1>téléchargez-les et ouvrez-les dans votre navigateur</1>." }, "editor": { "quickly_focus": "Vous pouvez rapidement vous concentrer sur ce champ en appuyant sur ⌘ + E", @@ -714,7 +777,8 @@ "refetch": "Re-fetch a été mis en file d'attente !", "full_page_archive": "La création de l'archive de la page complète a été déclenchée", "delete_from_list": "Le favori a été supprimé de la liste", - "clipboard_copied": "Le lien a été ajouté à votre presse-papiers !" + "clipboard_copied": "Le lien a été ajouté à votre presse-papiers !", + "preserve_pdf": "La conservation en PDF a été déclenchée" }, "lists": { "created": "La liste a été créée !", @@ -772,7 +836,14 @@ "year_s_ago": " Il y a {years} an(s)", "history": "Recherches récentes", "title_contains": "Le titre contient", - "title_does_not_contain": "Le titre ne contient pas" + "title_does_not_contain": "Le titre ne contient pas", + "is_broken_link": "A un lien brisé", + "tags": "Balises", + "no_suggestions": "Pas de suggestions", + "filters": "Filtres", + "is_not_broken_link": "A un lien fonctionnel", + "lists": "Listes", + "feeds": "Flux" }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/ga/translation.json b/apps/web/lib/i18n/locales/ga/translation.json index abf4ecaf..b132ca45 100644 --- a/apps/web/lib/i18n/locales/ga/translation.json +++ b/apps/web/lib/i18n/locales/ga/translation.json @@ -39,7 +39,9 @@ }, "quota": "Cuóta", "bookmarks": "Leabhair mharcála", - "storage": "Stóráil" + "storage": "Stóráil", + "pdf": "PDF Cartlainne", + "default": "Réamhshocrú" }, "actions": { "close": "Dún", @@ -84,7 +86,9 @@ "confirm": "Deimhnigh", "regenerate": "Athghinigh", "load_more": "Luchtaigh Níos Mó", - "edit_notes": "Nótaí a Chur in Eagar" + "edit_notes": "Nótaí a Chur in Eagar", + "preserve_as_pdf": "Caomhnaigh mar PDF", + "offline_copies": "Cóipeanna As Líne" }, "settings": { "ai": { @@ -98,7 +102,21 @@ "all_tagging": "Gach Clibeáil", "text_tagging": "Clibeáil Téacs", "image_tagging": "Clibeáil Íomhá", - "summarization": "Achoimre" + "summarization": "Achoimre", + "tag_style": "Stíl Clibe", + "auto_summarization_description": "Achoimrí a ghiniúint go huathoibríoch do do leabharmharcanna ag úsáid AI.", + "auto_tagging": "Uathchlibeáil", + "titlecase_spaces": "Cás teidil le spásanna", + "lowercase_underscores": "Cás íseal le fostríocaí", + "inference_language": "Teanga Inbhainte", + "titlecase_hyphens": "Cás teidil le fleiscíní", + "lowercase_hyphens": "Cás íseal le fleiscíní", + "lowercase_spaces": "Cás íseal le spásanna", + "inference_language_description": "Roghnaigh teanga do chlibeanna agus achoimrí arna nginiúint ag AI.", + "tag_style_description": "Roghnaigh conas ar cheart do chlibeanna uathghinte a bheith formáidithe.", + "auto_tagging_description": "Clibeanna a ghiniúint go huathoibríoch do do leabharmharcanna ag úsáid AI.", + "camelCase": "camelCase", + "auto_summarization": "Uathachoimriú" }, "webhooks": { "webhooks": "Crúcaí Gréasáin", @@ -210,6 +228,49 @@ "show": "Taispeáin leabhair mharcáilte atá cartlannaithe i gclibeanna agus i liostaí", "hide": "Folaigh leabharmharcanna cartlannaithe i gclibeanna agus i liostaí" } + }, + "reader_settings": { + "local_overrides_title": "Socruithe gléas-sonracha gníomhach", + "using_default": "Ag baint úsáide as réamhshocrú an chliaint", + "clear_override_hint": "Glan sárú gléis chun socrú ginearálta a úsáid ({{value}})", + "font_size": "Méid Cló", + "font_family": "Cló-Aicme", + "preview_inline": "(réamhamharc)", + "tooltip_preview": "Athruithe réamhamhairc neamhshábháilte", + "save_to_all_devices": "Gach gléas", + "tooltip_local": "Tá socruithe gléis difriúil ó shocruithe ginearálta", + "reset_preview": "Athshocraigh réamhamharc", + "mono": "Monaspás", + "line_height": "Airde Líne", + "tooltip_default": "Socruithe léitheoireachta", + "title": "Socruithe Léitheora", + "serif": "Searif", + "preview": "Réamhamharc", + "not_set": "Níl sé socraithe", + "clear_local_overrides": "Glan socruithe gléis", + "preview_text": "Léimeann an sionnach rua tapa thar an madra leisciúil. Seo an chuma a bheidh ar théacs do radhairc léitheora.", + "local_overrides_cleared": "Tá socruithe gléas-sonracha glanta", + "local_overrides_description": "Tá socruithe léitheora ag an ngléas seo atá difriúil ó do réamhshocruithe domhanda:", + "clear_defaults": "Glan gach réamhshocrú", + "description": "Cumraigh socruithe téacs réamhshocraithe do radharc an léitheora. Déantar na socruithe seo a shioncronú ar fud do ghléasanna go léir.", + "defaults_cleared": "Tá réamhshocruithe léitheora glanta", + "save_hint": "Sábháil socruithe don ghléas seo amháin nó sioncronaigh ar gach gléas", + "save_as_default": "Sábháil mar réamhshocrú", + "save_to_device": "An gléas seo", + "sans": "Sans Searif", + "tooltip_preview_and_local": "Athruithe réamhamhairc neamhshábháilte; tá socruithe gléis difriúil ó shocruithe ginearálta", + "adjust_hint": "Coigeartaigh na socruithe thuas chun athruithe a réamhamharc" + }, + "avatar": { + "upload": "Uaslódáil avatar", + "change": "Athraigh avatar", + "remove_confirm_title": "Bain avatar?", + "updated": "Nuashonraíodh avatar", + "removed": "Baineadh avatar", + "description": "Uaslódáil íomhá chearnach le húsáid mar avatar.", + "remove_confirm_description": "Glanfaidh sé seo an grianghraf próifíle atá agat faoi láthair.", + "title": "Grianghraf Próifíle", + "remove": "Bain avatar" } }, "feeds": { @@ -223,6 +284,7 @@ "import_export_bookmarks": "Iompórtáil / Easpórtáil Leabharmharcanna", "import_bookmarks_from_html_file": "Iompórtáil Leabharmharcanna ó chomhad HTML", "import_bookmarks_from_pocket_export": "Iompórtáil Leabharmharcanna ó onnmhairiú Pocket", + "import_bookmarks_from_matter_export": "Iompórtáil Leabharmharcanna ó onnmhairiú Matter", "import_bookmarks_from_omnivore_export": "Iompórtáil Leabharcmharcanna ó onnmhairiú Omnivore", "import_bookmarks_from_linkwarden_export": "Iompórtáil Leabharmharcanna ó onnmhairiú Linkwarden", "import_bookmarks_from_karakeep_export": "Iompórtáil Leabharmharcanna ó onnmhairiú Karakeep", @@ -537,7 +599,14 @@ "or": "Nó", "history": "Cuardaigh Déanaí", "title_contains": "Tá Teideal I Láthair", - "title_does_not_contain": "Níl Teideal I Láthair" + "title_does_not_contain": "Níl Teideal I Láthair", + "is_broken_link": "Tá Nasc Briste Ann", + "tags": "Clibeanna", + "no_suggestions": "Níl moltaí ar bith ann", + "filters": "Scagairí", + "is_not_broken_link": "Tá Nasc Oibre Ann", + "lists": "Liostaí", + "feeds": "Fothaí" }, "editor": { "disabled_submissions": "Tá aighneachtaí díchumasaithe", @@ -605,7 +674,8 @@ "refetch": "Cuireadh atógáil sa scuaine!", "full_page_archive": "Tá cruthú Cartlainne Leathanach Iomlán tosaithe", "delete_from_list": "Scriosadh an leabharmharc ón liosta", - "clipboard_copied": "Tá an nasc curtha le do ghearrthaisce!" + "clipboard_copied": "Tá an nasc curtha le do ghearrthaisce!", + "preserve_pdf": "Tá caomhnú PDF tosaithe" }, "lists": { "created": "Cruthaíodh liosta!", @@ -778,7 +848,8 @@ "tabs": { "content": "Ábhar", "details": "Sonraí" - } + }, + "archive_info": "Seans nach ndéanfaidh cartlanna rindreáil i gceart inline má tá Javascript ag teastáil uathu. Chun na torthaí is fearr a fháil, <1>íoslódáil é agus oscail i do bhrabhsálaí</1>." }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/gl/translation.json b/apps/web/lib/i18n/locales/gl/translation.json index 40dcc3a6..9fe11f1a 100644 --- a/apps/web/lib/i18n/locales/gl/translation.json +++ b/apps/web/lib/i18n/locales/gl/translation.json @@ -39,7 +39,9 @@ "summary": "Resumo", "quota": "Cota", "bookmarks": "Marcadores", - "storage": "Almacenamento" + "storage": "Almacenamento", + "pdf": "PDF Arquivado", + "default": "Predeterminado" }, "actions": { "favorite": "Marcar como favorito", @@ -84,7 +86,9 @@ "confirm": "Confirmar", "regenerate": "Rexenerar", "load_more": "Cargar máis", - "edit_notes": "Editar notas" + "edit_notes": "Editar notas", + "preserve_as_pdf": "Gardar como PDF", + "offline_copies": "Copias sen conexión" }, "tags": { "drag_and_drop_merging_info": "Arrastra e solta etiquetas sobre outras para unilas", @@ -117,7 +121,8 @@ "refetch": "Solicitouse a actualización!", "full_page_archive": "Pediuse un Arquivo de Páxina Completa", "delete_from_list": "O marcador borrouse da lista", - "clipboard_copied": "A ligazón copiouse no teu portapapeis!" + "clipboard_copied": "A ligazón copiouse no teu portapapeis!", + "preserve_pdf": "Activouse a preservación en PDF" }, "lists": { "updated": "A lista foi actualizada!", @@ -149,6 +154,7 @@ "import_export_bookmarks": "Importar / Exportar marcadores", "import_bookmarks_from_html_file": "Importar marcadores desde arquivo HTML", "import_bookmarks_from_pocket_export": "Importar marcadores desde Pocket", + "import_bookmarks_from_matter_export": "Importar marcadores desde Matter", "import_bookmarks_from_omnivore_export": "Importar marcadores desde Omnivore", "import_bookmarks_from_linkwarden_export": "Importar marcadores desde Linkwarden", "import_bookmarks_from_karakeep_export": "Importar marcadores desde Karakeep", @@ -180,6 +186,49 @@ "show": "Mostrar os marcadores arquivados en etiquetas e listas", "hide": "Ocultar os marcadores arquivados en etiquetas e listas" } + }, + "reader_settings": { + "local_overrides_title": "Axustes específicos do dispositivo activos", + "using_default": "Usando o predeterminado do cliente", + "clear_override_hint": "Limpa a anulación do dispositivo para usar a configuración global ({{value}})", + "font_size": "Tamaño da letra", + "font_family": "Familia tipográfica", + "preview_inline": "(vista previa)", + "tooltip_preview": "Cambios da vista previa sen gardar", + "save_to_all_devices": "Todos os dispositivos", + "tooltip_local": "Os axustes do dispositivo difiren dos globais", + "reset_preview": "Restabelecer a vista previa", + "mono": "Monoespazo", + "line_height": "Alto de liña", + "tooltip_default": "Axustes de lectura", + "title": "Axustes do Reader", + "serif": "Con serifas", + "preview": "Vista previa", + "not_set": "Sen axustar", + "clear_local_overrides": "Eliminar axustes do dispositivo", + "preview_text": "A raposa marrón rápida salta sobre o can preguiceiro. Así é como aparecerá o texto da vista do lector.", + "local_overrides_cleared": "Elimináronse os axustes específicos do dispositivo", + "local_overrides_description": "Este dispositivo ten parámetros de lector que difieren dos teus predeterminados globais:", + "clear_defaults": "Borrar todos os predeterminados", + "description": "Configure os axustes de texto predeterminados para a vista do lector. Estes axustes sincronízanse en todos os teus dispositivos.", + "defaults_cleared": "Elimináronse os valores predeterminados do lector", + "save_hint": "Garda os axustes só para este dispositivo ou sincronízaos en todos os dispositivos", + "save_as_default": "Gardar como predeterminado", + "save_to_device": "Este dispositivo", + "sans": "Sen serifas", + "tooltip_preview_and_local": "Cambios da vista previa sen gardar; os axustes do dispositivo difiren dos globais", + "adjust_hint": "Axusta os axustes de arriba para previsualizar os cambios" + }, + "avatar": { + "upload": "Subir avatar", + "change": "Cambiar o avatar", + "remove_confirm_title": "Queres eliminar o avatar?", + "updated": "Avatar actualizado", + "removed": "Avatar eliminado", + "description": "Sube unha imaxe cadrada para usar como avatar.", + "remove_confirm_description": "Isto borrará a túa foto de perfil actual.", + "title": "Foto de perfil", + "remove": "Eliminar o avatar" } }, "ai": { @@ -193,7 +242,21 @@ "all_tagging": "Todas as etiquetas", "text_tagging": "Etiquetaxe de texto", "image_tagging": "Etiquetaxe de imaxes", - "summarization": "Resumo" + "summarization": "Resumo", + "tag_style": "Estilo da etiqueta", + "auto_summarization_description": "Xera automaticamente resumos para os teus marcadores usando a intelixencia artificial.", + "auto_tagging": "Etiquetado automático", + "titlecase_spaces": "Maiúsculas e minúsculas con espazos", + "lowercase_underscores": "Minúsculas con guións baixos", + "inference_language": "Linguaxe dedución", + "titlecase_hyphens": "Maiúsculas só na primeira palabra con guións", + "lowercase_hyphens": "Minúsculas con guións", + "lowercase_spaces": "Minúsculas con espazos", + "inference_language_description": "Elixe a lingua para as etiquetas e os resumos xerados pola IA.", + "tag_style_description": "Elixe como se deben formatar as etiquetas xeradas automaticamente.", + "auto_tagging_description": "Xera automaticamente etiquetas para os teus marcadores usando a intelixencia artificial.", + "camelCase": "camelCase (a primeira palabra en minúsculas e as seguintes en maiúsculas)", + "auto_summarization": "Resumo automático" }, "feeds": { "rss_subscriptions": "Subscricións RSS", @@ -676,7 +739,8 @@ "tabs": { "content": "Contido", "details": "Detalles" - } + }, + "archive_info": "É posible que os arquivos non se representen correctamente en liña se requiren Javascript. Para obter os mellores resultados, <1>descárgueo e ábreo no navegador</1>." }, "editor": { "quickly_focus": "Podes enfocar este campo pulsando ⌘ + E", @@ -775,7 +839,14 @@ "year_s_ago": " Hai anos", "history": "Buscas recentes", "title_contains": "O título contén", - "title_does_not_contain": "O título non contén" + "title_does_not_contain": "O título non contén", + "is_broken_link": "Ten Ligazón Rota", + "tags": "Etiquetas", + "no_suggestions": "Sen suxestións", + "filters": "Filtros", + "is_not_broken_link": "Ten Ligazón Válida", + "lists": "Listas", + "feeds": "Fontes" }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/hr/translation.json b/apps/web/lib/i18n/locales/hr/translation.json index bd7a7a9d..7ef093d0 100644 --- a/apps/web/lib/i18n/locales/hr/translation.json +++ b/apps/web/lib/i18n/locales/hr/translation.json @@ -175,7 +175,9 @@ "summary": "Sažetak", "quota": "Kvota", "bookmarks": "Oznake", - "storage": "Pohrana" + "storage": "Pohrana", + "pdf": "Arhivirani PDF", + "default": "Zadano" }, "settings": { "ai": { @@ -189,7 +191,21 @@ "text_tagging": "Označavanje teksta", "image_tagging": "Označavanje slika", "summarization": "Sažetak", - "all_tagging": "Sve oznake" + "all_tagging": "Sve oznake", + "tag_style": "Stil oznake", + "auto_summarization_description": "Automatski generiraj sažetke za svoje knjižne oznake pomoću AI-ja.", + "auto_tagging": "Automatsko označavanje", + "titlecase_spaces": "Veliko početno slovo s razmacima", + "lowercase_underscores": "Mala slova s podvlakama", + "inference_language": "Jezik zaključka", + "titlecase_hyphens": "Veliko početno slovo s crticama", + "lowercase_hyphens": "Mala slova s crticama", + "lowercase_spaces": "Mala slova s razmacima", + "inference_language_description": "Odaberi jezik za oznake i sažetke generirane pomoću AI-a.", + "tag_style_description": "Odaberi kako će tvoje automatski generirane oznake biti formatirane.", + "auto_tagging_description": "Automatski generiraj oznake za svoje knjižne oznake pomoću AI-ja.", + "camelCase": "camelCase", + "auto_summarization": "Automatsko sažimanje" }, "import": { "import_bookmarks_from_html_file": "Import knjižnih oznaka iz HTML datoteke", @@ -197,6 +213,7 @@ "import_export_bookmarks": "Import / Export knjižnih oznaka", "import_bookmarks_from_linkwarden_export": "Import oznaka iz Linkwarden exporta", "import_bookmarks_from_pocket_export": "Import oznaka iz Pocket exporta", + "import_bookmarks_from_matter_export": "Import oznaka iz Matter exporta", "import_bookmarks_from_karakeep_export": "Import oznaka iz Karakeep exporta", "export_links_and_notes": "Export veza i bilješki", "imported_bookmarks": "Importirane oznake", @@ -227,6 +244,49 @@ "show": "Prikaži arhivirane oznake u oznakama i popisima", "hide": "Sakrij arhivirane oznake u oznakama i popisima" } + }, + "reader_settings": { + "local_overrides_title": "Aktivne postavke specifične za uređaj", + "using_default": "Korištenje zadanih postavki klijenta", + "clear_override_hint": "Obriši nadjačavanje uređaja za korištenje globalne postavke ({{value}})", + "font_size": "Veličina fonta", + "font_family": "Vrsta fonta", + "preview_inline": "(pregled)", + "tooltip_preview": "Nespremljene promjene pregleda", + "save_to_all_devices": "Svi uređaji", + "tooltip_local": "Postavke uređaja razlikuju se od globalnih", + "reset_preview": "Resetiraj pregled", + "mono": "Monospace", + "line_height": "Visina retka", + "tooltip_default": "Postavke čitanja", + "title": "Postavke čitača", + "serif": "Serif", + "preview": "Pregled", + "not_set": "Nije postavljeno", + "clear_local_overrides": "Očisti postavke uređaja", + "preview_text": "Smeđi lisac brzo skače preko lijenog psa. Ovako će izgledati tekst u prikazu čitača.", + "local_overrides_cleared": "Postavke specifične za uređaj su očišćene", + "local_overrides_description": "Ovaj uređaj ima postavke čitanja koje se razlikuju od tvojih globalnih zadanih postavki:", + "clear_defaults": "Očisti sve zadane vrijednosti", + "description": "Konfiguriraj zadane postavke teksta za prikaz čitača. Ove se postavke sinkroniziraju na svim tvojim uređajima.", + "defaults_cleared": "Zadane postavke čitača su očišćene", + "save_hint": "Spremi postavke samo za ovaj uređaj ili sinkroniziraj na svim uređajima", + "save_as_default": "Spremi kao zadane", + "save_to_device": "Ovaj uređaj", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Nespremljene promjene pregleda; postavke uređaja razlikuju se od globalnih", + "adjust_hint": "Prilagodite postavke iznad za pregled promjena" + }, + "avatar": { + "upload": "Učitaj avatar", + "change": "Promijeni avatar", + "remove_confirm_title": "Ukloniti avatar?", + "updated": "Avatar ažuriran", + "removed": "Avatar uklonjen", + "description": "Učitaj kvadratnu sliku koju ćeš koristiti kao avatar.", + "remove_confirm_description": "Ovim ćeš ukloniti trenutnu fotku profila.", + "title": "Fotka profila", + "remove": "Ukloni avatar" } }, "api_keys": { @@ -523,7 +583,9 @@ "confirm": "Potvrdi", "regenerate": "Ponovo stvori", "load_more": "Učitaj više", - "edit_notes": "Uredi bilješke" + "edit_notes": "Uredi bilješke", + "preserve_as_pdf": "Spremi kao PDF", + "offline_copies": "Izvanmrežne kopije" }, "highlights": { "no_highlights": "Još nemate nijednu istaknutu stavku." @@ -649,7 +711,8 @@ "tabs": { "content": "Sadržaj", "details": "Detalji" - } + }, + "archive_info": "Arhive se možda neće ispravno prikazati inline ako zahtijevaju Javascript. Za najbolje rezultate, <1>preuzmite ih i otvorite u svom pregledniku</1>." }, "editor": { "quickly_focus": "Možete brzo fokusirati ovo polje pritiskanjem ⌘ + E", @@ -717,7 +780,8 @@ "refetch": "Ponovno preuzimanje je stavljeno u čekanje!", "full_page_archive": "Pokrenuto je stvaranje potpune arhive stranice", "delete_from_list": "Oznaka je izbrisana s popisa", - "clipboard_copied": "Veza je dodana u vaš međuspremnik!" + "clipboard_copied": "Veza je dodana u vaš međuspremnik!", + "preserve_pdf": "Spremanje u PDF formatu je pokrenuto" }, "lists": { "created": "Popis je kreiran!", @@ -775,7 +839,14 @@ "year_s_ago": " Godina(e) prije", "history": "Nedavne pretrage", "title_contains": "Naslov sadrži", - "title_does_not_contain": "Naslov ne sadrži" + "title_does_not_contain": "Naslov ne sadrži", + "is_broken_link": "Ima pokvareni link", + "tags": "Oznake", + "no_suggestions": "Nema prijedloga", + "filters": "Filtri", + "is_not_broken_link": "Ima radni link", + "lists": "Popisi", + "feeds": "Kanali" }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/hu/translation.json b/apps/web/lib/i18n/locales/hu/translation.json index 72439434..1399e4a8 100644 --- a/apps/web/lib/i18n/locales/hu/translation.json +++ b/apps/web/lib/i18n/locales/hu/translation.json @@ -42,7 +42,9 @@ "confirm": "Megerősít", "regenerate": "Újragenerálás", "load_more": "Továbbiak betöltése", - "edit_notes": "Jegyzetek szerkesztése" + "edit_notes": "Jegyzetek szerkesztése", + "preserve_as_pdf": "Mentés PDF-ként", + "offline_copies": "Offline példányok" }, "settings": { "user_settings": "Felhasználói beállítások", @@ -73,6 +75,49 @@ "show": "Archivált könyvjelzők megjelenítése címkékben és listákban", "hide": "Archivált könyvjelzők elrejtése címkékben és listákban" } + }, + "reader_settings": { + "local_overrides_title": "Eszközspecifikus beállítások aktívak", + "using_default": "Ügyfél alapértelmezettjének használata", + "clear_override_hint": "Eszközfelülírás törlése a globális beállítás ({{value}}) használatához", + "font_size": "Betűméret", + "font_family": "Betűtípus családja", + "preview_inline": "(előnézet)", + "tooltip_preview": "El nem mentett előnézeti módosítások", + "save_to_all_devices": "Minden eszköz", + "tooltip_local": "Az eszköz beállításai eltérnek a globálistól", + "reset_preview": "Előnézet visszaállítása", + "mono": "Monospace", + "line_height": "Sortávolság", + "tooltip_default": "Olvasási beállítások", + "title": "Olvasó beállításai", + "serif": "Serif", + "preview": "Előnézet", + "not_set": "Nincs beállítva", + "clear_local_overrides": "Eszközbeállítások törlése", + "preview_text": "A gyors barna róka átugorja a lusta kutyát. Így fog megjelenni az olvasónézeti szöveg.", + "local_overrides_cleared": "Az eszközspecifikus beállítások törölve lettek", + "local_overrides_description": "Ennek az eszköznek az olvasási beállításai eltérnek a globális alapértelmezésektől:", + "clear_defaults": "Összes alapértelmezett törlése", + "description": "Az olvasónézet alapértelmezett szövegbeállításainak konfigurálása. Ezek a beállítások szinkronizálva vannak az összes eszközén.", + "defaults_cleared": "Az olvasó alapértelmezései törölve", + "save_hint": "Beállítások mentése csak ehhez az eszközhöz, vagy szinkronizálás minden eszközre", + "save_as_default": "Mentés alapértelmezettként", + "save_to_device": "Ez az eszköz", + "sans": "Sans Serif", + "tooltip_preview_and_local": "El nem mentett előnézeti módosítások; az eszköz beállításai eltérnek a globálistól", + "adjust_hint": "A módosítások előnézetéhez állítsa be a fenti beállításokat" + }, + "avatar": { + "upload": "Avatár feltöltése", + "change": "Avatár módosítása", + "remove_confirm_title": "Avatár eltávolítása?", + "updated": "Avatár frissítve", + "removed": "Avatár eltávolítva", + "description": "Tölts fel egy négyzet alakú képet, amit avatárként használhatsz.", + "remove_confirm_description": "Ezzel törlöd a jelenlegi profilképed.", + "title": "Profilkép", + "remove": "Avatár eltávolítása" } }, "webhooks": { @@ -104,7 +149,21 @@ "images_prompt": "Utasítás képpel", "text_tagging": "Szöveg címkézés", "image_tagging": "Kép címkézés", - "summarization": "Összesítés" + "summarization": "Összesítés", + "tag_style": "Címke stílusa", + "auto_summarization_description": "A MI használatával automatikusan összefoglalókat generálhatsz a könyvjelzőidhez.", + "auto_tagging": "Automatikus címkézés", + "titlecase_spaces": "Címzett nagybetűs, szóközökkel", + "lowercase_underscores": "Kisbetűs, aláhúzásokkal", + "inference_language": "Következtetési nyelv", + "titlecase_hyphens": "Címzett nagybetűs, kötőjelekkel", + "lowercase_hyphens": "Kisbetűs, kötőjelekkel", + "lowercase_spaces": "Kisbetűs, szóközökkel", + "inference_language_description": "Válaszd ki az AI által generált címkék és összefoglalók nyelvét.", + "tag_style_description": "Válaszd ki, hogyan legyenek formázva az automatikusan létrehozott címkék.", + "auto_tagging_description": "A MI használatával automatikusan címkéket generálhatsz a könyvjelzőidhez.", + "camelCase": "camelCase", + "auto_summarization": "Automatikus összefoglalás" }, "api_keys": { "new_api_key": "Új API kulcs", @@ -125,6 +184,7 @@ "import_export_bookmarks": "Könyvjelző importálása / exportálása", "import_bookmarks_from_html_file": "Könyvjelző importálása HTML fájlból", "import_bookmarks_from_pocket_export": "Könyvjelző importálása Pocket-ből", + "import_bookmarks_from_matter_export": "Könyvjelző importálása Matter-ből", "import_bookmarks_from_linkwarden_export": "Könyvjelző importálása Linkwarden-ből", "export_links_and_notes": "Jegyzetek és hivatkozások exportálása", "imported_bookmarks": "Importált könyvjelzők", @@ -387,7 +447,9 @@ "summary": "Összegzés", "quota": "Keret", "bookmarks": "Könyvjelzők", - "storage": "Tárhely" + "storage": "Tárhely", + "pdf": "Archivált PDF", + "default": "Alapértelmezett" }, "editor": { "import_as_text": "Importálás szöveges könyvjelzőként", @@ -486,7 +548,14 @@ "year_s_ago": " Év(ek)kel ezelőtt", "history": "Legutóbbi keresések", "title_contains": "A cím tartalmazza", - "title_does_not_contain": "A cím nem tartalmazza" + "title_does_not_contain": "A cím nem tartalmazza", + "is_broken_link": "Van hibás link", + "tags": "Címkék", + "no_suggestions": "Nincsenek javaslatok", + "filters": "Szűrők", + "is_not_broken_link": "Van működő link", + "lists": "Listák", + "feeds": "Hírcsatornák" }, "lists": { "manual_list": "Manuális lista", @@ -745,7 +814,8 @@ "tabs": { "content": "Tartalom", "details": "Részletek" - } + }, + "archive_info": "Lehetséges, hogy a JavaScriptet igénylő archívumok nem jelennek meg helyesen beágyazva. A legjobb eredmény érdekében <1>töltsd le és nyisd meg a böngésződben</1>." }, "dialogs": { "bookmarks": { @@ -760,7 +830,8 @@ "refetch": "Újra begyűjtés beütemezve!", "full_page_archive": "Minden oldal lecserélése beütemezésre került", "delete_from_list": "A könyvjelző törlődött a listából", - "clipboard_copied": "A hivatkozás kimásolva a memóriába!" + "clipboard_copied": "A hivatkozás kimásolva a memóriába!", + "preserve_pdf": "A PDF archiválás elindult." }, "lists": { "created": "A hivatkozás létrejött!", diff --git a/apps/web/lib/i18n/locales/it/translation.json b/apps/web/lib/i18n/locales/it/translation.json index f154466d..d7fa773d 100644 --- a/apps/web/lib/i18n/locales/it/translation.json +++ b/apps/web/lib/i18n/locales/it/translation.json @@ -42,7 +42,9 @@ "confirm": "Conferma", "regenerate": "Rigenera", "load_more": "Carica altro", - "edit_notes": "Modifica note" + "edit_notes": "Modifica note", + "preserve_as_pdf": "Salva come PDF", + "offline_copies": "Copie offline" }, "common": { "attachments": "Allegati", @@ -84,7 +86,9 @@ "summary": "Riepilogo", "quota": "Quota", "bookmarks": "Segnalibri", - "storage": "Archiviazione" + "storage": "Archiviazione", + "pdf": "PDF archiviato", + "default": "Predefinito" }, "settings": { "broken_links": { @@ -114,6 +118,49 @@ "show": "Mostra i segnalibri archiviati in tag e liste", "hide": "Nascondi i segnalibri archiviati in tag e liste" } + }, + "reader_settings": { + "local_overrides_title": "Impostazioni specifiche del dispositivo attive", + "using_default": "Utilizzo predefinito del client", + "clear_override_hint": "Cancella la sostituzione del dispositivo per utilizzare l'impostazione globale ({{value}})", + "font_size": "Dimensione del font", + "font_family": "Famiglia di caratteri", + "preview_inline": "(anteprima)", + "tooltip_preview": "Modifiche all'anteprima non salvate", + "save_to_all_devices": "Tutti i dispositivi", + "tooltip_local": "Le impostazioni del dispositivo differiscono da quelle globali", + "reset_preview": "Ripristina l'anteprima", + "mono": "Monospace", + "line_height": "Altezza della linea", + "tooltip_default": "Impostazioni di lettura", + "title": "Impostazioni lettore", + "serif": "Serif", + "preview": "Anteprima", + "not_set": "Non impostato", + "clear_local_overrides": "Cancella impostazioni del dispositivo", + "preview_text": "The quick brown fox jumps over the lazy dog. Ecco come apparirà il testo nella visualizzazione del lettore.", + "local_overrides_cleared": "Le impostazioni specifiche del dispositivo sono state cancellate", + "local_overrides_description": "Questo dispositivo ha impostazioni del lettore diverse da quelle predefinite globali:", + "clear_defaults": "Cancella tutti i predefiniti", + "description": "Configura le impostazioni di testo predefinite per la visualizzazione del lettore. Queste impostazioni si sincronizzano su tutti i tuoi dispositivi.", + "defaults_cleared": "Le impostazioni predefinite del lettore sono state cancellate", + "save_hint": "Salva le impostazioni solo per questo dispositivo o sincronizza su tutti i dispositivi", + "save_as_default": "Salva come predefinito", + "save_to_device": "Questo dispositivo", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Modifiche all'anteprima non salvate; le impostazioni del dispositivo differiscono da quelle globali", + "adjust_hint": "Regola le impostazioni sopra per visualizzare l'anteprima delle modifiche" + }, + "avatar": { + "upload": "Carica avatar", + "change": "Cambia avatar", + "remove_confirm_title": "Rimuovere l'avatar?", + "updated": "Avatar aggiornato", + "removed": "Avatar rimosso", + "description": "Carica un'immagine quadrata da usare come avatar.", + "remove_confirm_description": "Ehm... rimuoverai la tua attuale foto del profilo.", + "title": "Foto profilo", + "remove": "Rimuovi avatar" } }, "back_to_app": "Torna all'App", @@ -129,7 +176,21 @@ "image_tagging": "Tagging immagini", "text_tagging": "Tagging testo", "all_tagging": "Tutte le etichette", - "summarization": "Riassunto" + "summarization": "Riassunto", + "tag_style": "Stile etichetta", + "auto_summarization_description": "Genera automaticamente riassunti per i tuoi segnalibri usando l'AI.", + "auto_tagging": "Tagging automatico", + "titlecase_spaces": "Maiuscola con spazi", + "lowercase_underscores": "Minuscolo con trattini bassi", + "inference_language": "Lingua di inferenza", + "titlecase_hyphens": "Maiuscola con trattini", + "lowercase_hyphens": "Minuscolo con trattini", + "lowercase_spaces": "Minuscolo con spazi", + "inference_language_description": "Scegli la lingua per i tag e i riepiloghi generati dall'AI.", + "tag_style_description": "Scegli come formattare le etichette generate automaticamente.", + "auto_tagging_description": "Genera automaticamente i tag per i tuoi segnalibri usando l'AI.", + "camelCase": "camelCase", + "auto_summarization": "Riassunto automatico" }, "feeds": { "rss_subscriptions": "Iscrizione RSS", @@ -140,6 +201,7 @@ "import": { "import_export": "Importa / Esporta", "import_bookmarks_from_pocket_export": "Importa segnalibri da esportazione Pocket", + "import_bookmarks_from_matter_export": "Importa segnalibri da esportazione Matter", "import_bookmarks_from_karakeep_export": "Importa segnalibri da esportazione Karakeep", "export_links_and_notes": "Esporta link e note", "imported_bookmarks": "Segnalibri importati", @@ -705,7 +767,8 @@ "tabs": { "content": "Contenuto", "details": "Dettagli" - } + }, + "archive_info": "Gli archivi potrebbero non essere visualizzati correttamente in linea se richiedono Javascript. Per risultati ottimali, <1>scaricalo e aprilo nel tuo browser</1>." }, "toasts": { "bookmarks": { @@ -714,7 +777,8 @@ "refetch": "L'aggiornamento è stato messo in coda!", "full_page_archive": "L'archivio della pagina completa è stato attivato", "delete_from_list": "Il segnalibro è stato eliminato dalla lista", - "clipboard_copied": "Il link è stato copiato!" + "clipboard_copied": "Il link è stato copiato!", + "preserve_pdf": "È stato attivato il salvataggio in PDF" }, "lists": { "created": "Lista creata!", @@ -772,7 +836,14 @@ "year_s_ago": " Anni fa", "history": "Ricerche recenti", "title_contains": "Il titolo contiene", - "title_does_not_contain": "Il titolo non contiene" + "title_does_not_contain": "Il titolo non contiene", + "is_broken_link": "Ha Link Non Funzionante", + "tags": "Tag", + "no_suggestions": "Nessun suggerimento", + "filters": "Filtri", + "is_not_broken_link": "Ha Link Funzionante", + "lists": "Elenchi", + "feeds": "Feed" }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/ja/translation.json b/apps/web/lib/i18n/locales/ja/translation.json index b6c350e2..58315b3e 100644 --- a/apps/web/lib/i18n/locales/ja/translation.json +++ b/apps/web/lib/i18n/locales/ja/translation.json @@ -42,7 +42,9 @@ "confirm": "確認", "regenerate": "再生成", "load_more": "もっと読み込む", - "edit_notes": "注釈を編集" + "edit_notes": "注釈を編集", + "preserve_as_pdf": "PDFとして保存する", + "offline_copies": "オフラインコピー" }, "admin": { "actions": { @@ -196,6 +198,49 @@ "title": "アーカイブされたブックマーク", "hide": "タグとリストにアーカイブされたブックマークを非表示にする" } + }, + "reader_settings": { + "local_overrides_title": "デバイス固有の設定が有効", + "using_default": "クライアントの既定を使用中", + "clear_override_hint": "デバイスのオーバーライドをクリアして、全体設定 ({{value}}) を使用します", + "font_size": "フォントサイズ", + "font_family": "フォントファミリー", + "preview_inline": "(プレビュー)", + "tooltip_preview": "未保存のプレビュー変更", + "save_to_all_devices": "すべてのデバイス", + "tooltip_local": "デバイス設定が全体設定と異なります", + "reset_preview": "プレビューをリセット", + "mono": "等幅", + "line_height": "行の高さ", + "tooltip_default": "リーディング設定", + "title": "リーダー設定", + "serif": "セリフ", + "preview": "プレビュー", + "not_set": "未設定", + "clear_local_overrides": "デバイス設定をクリア", + "preview_text": "すばやい茶色のキツネがのろまな犬を飛び越えます。リーダー表示のテキストはこんな感じになります。", + "local_overrides_cleared": "デバイス固有の設定がクリアされました", + "local_overrides_description": "このデバイスには、グローバル既定と異なるリーダー設定があります。", + "clear_defaults": "すべての既定をクリア", + "description": "リーダー表示の既定のテキスト設定を構成します。これらの設定は、すべてのデバイス間で同期されます。", + "defaults_cleared": "リーダーの既定がクリアされました", + "save_hint": "このデバイスのみの設定を保存するか、すべてのデバイス間で同期します", + "save_as_default": "既定として保存", + "save_to_device": "このデバイス", + "sans": "サンセリフ", + "tooltip_preview_and_local": "未保存のプレビュー変更; デバイス設定が全体設定と異なります", + "adjust_hint": "変更をプレビューするには、上記の設定を調整してください" + }, + "avatar": { + "upload": "アバターをアップロードする", + "change": "アバターを変更する", + "remove_confirm_title": "アバターを削除する?", + "updated": "アバターを更新したで", + "removed": "アバターを削除したで", + "description": "アバターとして使う正方形の画像をアップロードしてちょ。", + "remove_confirm_description": "現在のプロフィール写真が消去されるけど、ええんか?", + "title": "プロフィール画像", + "remove": "アバターを削除する" } }, "ai": { @@ -209,7 +254,21 @@ "all_tagging": "すべてのタグ付け", "text_tagging": "テキストタグ付け", "image_tagging": "画像タグ付け", - "summarization": "要約" + "summarization": "要約", + "tag_style": "タグのスタイル", + "auto_summarization_description": "AIを使ってブックマークの要約を自動生成する。", + "auto_tagging": "自動タグ付け", + "titlecase_spaces": "タイトルケース、スペース区切り", + "lowercase_underscores": "小文字、アンダースコア区切り", + "inference_language": "推論言語", + "titlecase_hyphens": "タイトルケース、ハイフン区切り", + "lowercase_hyphens": "小文字、ハイフン区切り", + "lowercase_spaces": "小文字、スペース区切り", + "inference_language_description": "AIが生成するタグや概要の言語を選んでくれ。", + "tag_style_description": "自動生成されるタグの書式を選んでくれ。", + "auto_tagging_description": "AIを使ってブックマークのタグを自動生成する。", + "camelCase": "camelCase", + "auto_summarization": "自動要約" }, "import": { "import_export_bookmarks": "ブックマークのインポート/エクスポート", @@ -217,6 +276,7 @@ "import_bookmarks_from_karakeep_export": "Karakeep エクスポートからブックマークをインポート", "imported_bookmarks": "インポートされたブックマーク", "import_bookmarks_from_pocket_export": "Pocketのエクスポートからブックマークをインポート", + "import_bookmarks_from_matter_export": "Matterのエクスポートからブックマークをインポート", "import_bookmarks_from_omnivore_export": "Omnivoreエクスポートからブックマークをインポート", "export_links_and_notes": "リンクとメモをエクスポートする", "import_export": "インポート/エクスポート", @@ -517,7 +577,9 @@ "summary": "概要", "quota": "割り当て", "bookmarks": "ブックマーク", - "storage": "ストレージ" + "storage": "ストレージ", + "pdf": "PDFをアーカイブしたよ", + "default": "既定" }, "layouts": { "grid": "グリッド", @@ -677,7 +739,14 @@ "year_s_ago": " ~年前", "history": "最近の検索", "title_contains": "タイトルに含む", - "title_does_not_contain": "タイトルに含まない" + "title_does_not_contain": "タイトルに含まない", + "is_broken_link": "リンク切れ", + "tags": "タグ", + "no_suggestions": "サジェストはありません", + "filters": "フィルター", + "is_not_broken_link": "リンクは有効です", + "lists": "リスト", + "feeds": "フィード" }, "preview": { "cached_content": "キャッシュされたコンテンツ", @@ -686,7 +755,8 @@ "tabs": { "content": "コンテンツ", "details": "詳細" - } + }, + "archive_info": "アーカイブは Javascript を必要とする場合、インラインで正しく表示されないことがあります。最良の結果を得るには、<1>ダウンロードしてブラウザで開いてください</1>。" }, "editor": { "quickly_focus": "⌘ + E を押すと、このフィールドにすばやくフォーカスできます", @@ -760,7 +830,8 @@ "full_page_archive": "フルページアーカイブの作成が開始されました", "delete_from_list": "ブックマークがリストから削除されました", "deleted": "ブックマークが削除されたよ!", - "refetch": "再取得をエンキューしたぞ!" + "refetch": "再取得をエンキューしたぞ!", + "preserve_pdf": "PDF保存が開始されたよ" }, "lists": { "created": "リストが作成されました!", diff --git a/apps/web/lib/i18n/locales/ko/translation.json b/apps/web/lib/i18n/locales/ko/translation.json index 7af4fd3e..52be7917 100644 --- a/apps/web/lib/i18n/locales/ko/translation.json +++ b/apps/web/lib/i18n/locales/ko/translation.json @@ -39,7 +39,9 @@ "description": "설명", "quota": "할당량", "bookmarks": "북마크", - "storage": "저장 공간" + "storage": "저장 공간", + "pdf": "보관된 PDF", + "default": "기본값" }, "layouts": { "list": "목록", @@ -90,7 +92,9 @@ "confirm": "확인", "regenerate": "다시 생성", "load_more": "더 불러오기", - "edit_notes": "노트 편집" + "edit_notes": "노트 편집", + "preserve_as_pdf": "PDF로 보존", + "offline_copies": "오프라인 사본" }, "tags": { "unused_tags": "사용되지 않은 태그", @@ -154,7 +158,14 @@ "year_s_ago": " 년 전", "history": "최근 검색어", "title_contains": "제목에 다음 내용이 포함됨", - "title_does_not_contain": "제목에 다음 내용이 포함되지 않음" + "title_does_not_contain": "제목에 다음 내용이 포함되지 않음", + "is_broken_link": "깨진 링크 있음", + "tags": "태그", + "no_suggestions": "추천 항목 없음", + "filters": "필터", + "is_not_broken_link": "작동하는 링크 있음", + "lists": "목록", + "feeds": "피드" }, "preview": { "view_original": "원본 보기", @@ -163,7 +174,8 @@ "tabs": { "content": "콘텐츠", "details": "세부 정보" - } + }, + "archive_info": "보관 파일은 Javascript가 필요한 경우 인라인으로 올바르게 렌더링되지 않을 수 있습니다. 최상의 결과를 얻으려면 <1>다운로드하여 브라우저에서 여세요</1>." }, "editor": { "quickly_focus": "⌘ + E를 누르면 이 필드에 초점이 옮겨집니다", @@ -237,7 +249,8 @@ "refetch": "다시 가져오기가 큐에 추가 되었습니다!", "full_page_archive": "전체 페이지 보관 생성이 요청되었습니다", "delete_from_list": "북마크를 목록에서 삭제했습니다", - "clipboard_copied": "링크를 클립보드에 복사했습니다!" + "clipboard_copied": "링크를 클립보드에 복사했습니다!", + "preserve_pdf": "PDF 보존이 시작되었습니다" }, "lists": { "created": "목록이 생성 되었습니다!", @@ -309,6 +322,49 @@ "show": "보관된 북마크를 태그 및 목록에 표시", "hide": "보관된 북마크를 태그 및 목록에서 숨기기" } + }, + "reader_settings": { + "local_overrides_title": "장치별 설정 활성화됨", + "using_default": "클라이언트 기본값 사용", + "clear_override_hint": "전역 설정을 사용하려면 기기 재정의를 지우세요 ({{value}})", + "font_size": "글꼴 크기", + "font_family": "글꼴", + "preview_inline": "(미리보기)", + "tooltip_preview": "저장되지 않은 미리 보기 변경 사항", + "save_to_all_devices": "모든 기기", + "tooltip_local": "기기 설정이 전역 설정과 다름", + "reset_preview": "미리 보기 초기화", + "mono": "고정폭", + "line_height": "줄 높이", + "tooltip_default": "읽기 설정", + "title": "글 뷰어 설정", + "serif": "세리프", + "preview": "미리 보기", + "not_set": "설정 안 됨", + "clear_local_overrides": "장치 설정 삭제", + "preview_text": "The quick brown fox jumps over the lazy dog. 글 뷰어 텍스트는 다음과 같이 표시됩니다.", + "local_overrides_cleared": "장치별 설정이 삭제됨", + "local_overrides_description": "이 장치에는 글로벌 기본값과 다른 글 뷰어 설정이 있습니다.", + "clear_defaults": "모든 기본값 삭제", + "description": "글 뷰어의 기본 텍스트 설정을 구성합니다. 이 설정은 모든 장치에서 동기화됩니다.", + "defaults_cleared": "글 뷰어 기본값이 삭제됨", + "save_hint": "이 기기 설정만 저장하거나 모든 기기에서 동기화", + "save_as_default": "기본값으로 저장", + "save_to_device": "이 기기", + "sans": "산세리프", + "tooltip_preview_and_local": "저장되지 않은 미리 보기 변경 사항, 기기 설정이 전역 설정과 다름", + "adjust_hint": "위에 설정을 조정하여 변경 사항 미리 보기" + }, + "avatar": { + "upload": "아바타 올려", + "change": "아바타 바꿔", + "remove_confirm_title": "아바타 지울까?", + "updated": "아바타 업데이트 완료", + "removed": "아바타 삭제 완료", + "description": "프로필 사진으로 쓸 정사각형 이미지를 올려 줘.", + "remove_confirm_description": "지금 프로필 사진이 싹 날아갈 텐데.", + "title": "프로필 사진", + "remove": "아바타 삭제" } }, "ai": { @@ -322,7 +378,21 @@ "all_tagging": "모든 태깅", "text_tagging": "텍스트 태깅", "image_tagging": "이미지 태깅", - "summarization": "요약" + "summarization": "요약", + "tag_style": "태그 스타일", + "auto_summarization_description": "AI를 사용하여 책갈피에 대한 요약을 자동으로 생성합니다.", + "auto_tagging": "자동 태그 지정", + "titlecase_spaces": "공백을 넣은 제목 케이스", + "lowercase_underscores": "밑줄을 넣은 소문자", + "inference_language": "추론 언어", + "titlecase_hyphens": "하이픈을 넣은 제목 케이스", + "lowercase_hyphens": "하이픈을 넣은 소문자", + "lowercase_spaces": "공백을 넣은 소문자", + "inference_language_description": "AI가 생성한 태그 및 요약에 사용할 언어를 선택합니다.", + "tag_style_description": "자동 생성 태그 형식을 선택하세요.", + "auto_tagging_description": "AI를 사용하여 책갈피에 대한 태그를 자동으로 생성합니다.", + "camelCase": "camelCase", + "auto_summarization": "자동 요약" }, "feeds": { "add_a_subscription": "구독 추가", @@ -336,6 +406,7 @@ "import_export_bookmarks": "북마크 가져오기 / 내보내기", "import_bookmarks_from_html_file": "HTML 파일에서 북마크 가져오기", "import_bookmarks_from_pocket_export": "Pocket 내보내기에서 북마크 가져오기", + "import_bookmarks_from_matter_export": "Matter 내보내기에서 북마크 가져오기", "import_bookmarks_from_omnivore_export": "Omnivore 내보내기에서 북마크 가져오기", "import_bookmarks_from_karakeep_export": "Karakeep 내보내기에서 북마크 가져오기", "export_links_and_notes": "링크와 주석 내보내기", diff --git a/apps/web/lib/i18n/locales/nb_NO/translation.json b/apps/web/lib/i18n/locales/nb_NO/translation.json index 6cfebfc3..8f1fde21 100644 --- a/apps/web/lib/i18n/locales/nb_NO/translation.json +++ b/apps/web/lib/i18n/locales/nb_NO/translation.json @@ -39,7 +39,9 @@ "title": "Tittel", "quota": "Kvote", "bookmarks": "Bokmerker", - "storage": "Lagring" + "storage": "Lagring", + "pdf": "Arkivert PDF", + "default": "Standard" }, "admin": { "users_list": { @@ -214,7 +216,9 @@ "confirm": "Bekreft", "regenerate": "Regenerer", "load_more": "Last inn mer", - "edit_notes": "Rediger notater" + "edit_notes": "Rediger notater", + "preserve_as_pdf": "Bevar som PDF", + "offline_copies": "Offline kopier" }, "settings": { "info": { @@ -238,6 +242,49 @@ "show": "Vis arkiverte bokmerker i tagger og lister", "hide": "Skjul arkiverte bokmerker i tagger og lister" } + }, + "reader_settings": { + "local_overrides_title": "Enhetsspesifikke innstillinger er aktive", + "using_default": "Bruker klientstandard", + "clear_override_hint": "Fjern overstyring av enhet for å bruke global innstilling ({{value}})", + "font_size": "Skriftstørrelse", + "font_family": "Skrifttype", + "preview_inline": "(forhåndsvisning)", + "tooltip_preview": "Ulagrede forhåndsvisningsendringer", + "save_to_all_devices": "Alle enheter", + "tooltip_local": "Enhetsinnstillingene er forskjellige fra de globale", + "reset_preview": "Tilbakestill forhåndsvisning", + "mono": "Monospace", + "line_height": "Linjehøyde", + "tooltip_default": "Leseinnstillinger", + "title": "Leserinnstillinger", + "serif": "Serif", + "preview": "Forhåndsvisning", + "not_set": "Ikke angitt", + "clear_local_overrides": "Fjern enhetsinnstillinger", + "preview_text": "Den rappe, brune reven hopper over den late hunden. Slik vil teksten i leservisningen din se ut.", + "local_overrides_cleared": "Enhetsspesifikke innstillinger er fjernet", + "local_overrides_description": "Denne enheten har leserinnstillinger som er forskjellige fra dine globale standardinnstillinger:", + "clear_defaults": "Fjern alle standarder", + "description": "Konfigurer standard tekstinnstillinger for leservisningen. Disse innstillingene synkroniseres på tvers av alle enhetene dine.", + "defaults_cleared": "Leserstandarder er fjernet", + "save_hint": "Lagre innstillinger bare for denne enheten eller synkroniser på tvers av alle enheter", + "save_as_default": "Lagre som standard", + "save_to_device": "Denne enheten", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Ulagrede forhåndsvisningsendringer; enhetsinnstillingene er forskjellige fra de globale", + "adjust_hint": "Juster innstillingene ovenfor for å forhåndsvise endringer" + }, + "avatar": { + "upload": "Last opp avatar", + "change": "Endre avatar", + "remove_confirm_title": "Fjerne avatar?", + "updated": "Avatar oppdatert", + "removed": "Avatar fjernet", + "description": "Last opp et kvadratisk bilde som avatar.", + "remove_confirm_description": "Dette vil fjerne ditt nåværende profilbilde.", + "title": "Profilbilde", + "remove": "Fjern avatar" } }, "ai": { @@ -251,7 +298,21 @@ "text_tagging": "Teksttagging", "image_tagging": "Bilde-tagging", "summarization": "Oppsummering", - "images_prompt": "Bildeledetekst" + "images_prompt": "Bildeledetekst", + "tag_style": "Stil for merkelapper", + "auto_summarization_description": "Generer automatisk sammendrag for bokmerkene dine ved hjelp av AI.", + "auto_tagging": "Automatisk merking", + "titlecase_spaces": "Tittel-case med mellomrom", + "lowercase_underscores": "Små bokstaver med understreker", + "inference_language": "Språk for inferens", + "titlecase_hyphens": "Tittel-case med bindestreker", + "lowercase_hyphens": "Små bokstaver med bindestreker", + "lowercase_spaces": "Små bokstaver med mellomrom", + "inference_language_description": "Velg språk for AI-genererte merkelapper og sammendrag.", + "tag_style_description": "Velg hvordan de automatisk genererte merkelappene dine skal formateres.", + "auto_tagging_description": "Generer automatisk tagger for bokmerkene dine ved hjelp av AI.", + "camelCase": "camelCase", + "auto_summarization": "Automatisk oppsummering" }, "import": { "import_bookmarks_from_omnivore_export": "Importer bokmerker fra Omnivore-eksport", @@ -259,6 +320,7 @@ "import_export_bookmarks": "Importer / eksporter bokmerker", "import_bookmarks_from_html_file": "Importer bokmerker fra HTML-fil", "import_bookmarks_from_pocket_export": "Importer bokmerker fra Pocket-eksport", + "import_bookmarks_from_matter_export": "Importer bokmerker fra Matter-eksport", "import_bookmarks_from_linkwarden_export": "Importer bokmerker fra Linkwarden-eksport", "import_bookmarks_from_karakeep_export": "Importer bokmerker fra Karakeepp-eksport", "export_links_and_notes": "Eksporter lenker og notater", @@ -671,7 +733,14 @@ "year_s_ago": " År siden", "history": "Nylige søk", "title_contains": "Tittel inneholder", - "title_does_not_contain": "Tittel inneholder ikke" + "title_does_not_contain": "Tittel inneholder ikke", + "is_broken_link": "Har ødelagt lenke", + "tags": "Merker", + "no_suggestions": "Ingen forslag", + "filters": "Filtere", + "is_not_broken_link": "Har fungerende lenke", + "lists": "Lister", + "feeds": "Feeder" }, "editor": { "text_toolbar": { @@ -739,7 +808,8 @@ "delete_from_list": "Bokmerket er sletta fra lista", "clipboard_copied": "Lenken er lagt til utklippstavlen din!", "updated": "Bokmerket er oppdatert!", - "deleted": "Bokmerket er slettet!" + "deleted": "Bokmerket er slettet!", + "preserve_pdf": "PDF-bevaring er trigget" }, "lists": { "created": "Liste er opprettet!", @@ -775,7 +845,8 @@ "tabs": { "content": "Innhold", "details": "Detaljer" - } + }, + "archive_info": "Det kan hende at arkiver ikke gjengis riktig direkte hvis de krever Javascript. For best resultat, <1>last ned og åpne i nettleseren din</1>." }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/nl/translation.json b/apps/web/lib/i18n/locales/nl/translation.json index 9510d215..c4987872 100644 --- a/apps/web/lib/i18n/locales/nl/translation.json +++ b/apps/web/lib/i18n/locales/nl/translation.json @@ -39,7 +39,9 @@ "summary": "Samenvatting", "quota": "Quota", "bookmarks": "Bladwijzers", - "storage": "Opslag" + "storage": "Opslag", + "pdf": "Gearchiveerde PDF", + "default": "Standaard" }, "layouts": { "list": "Lijst", @@ -90,7 +92,9 @@ "confirm": "Bevestigen", "regenerate": "Opnieuw genereren", "load_more": "Laad meer", - "edit_notes": "Notities bewerken" + "edit_notes": "Notities bewerken", + "preserve_as_pdf": "Opslaan als PDF", + "offline_copies": "Offline kopieën" }, "settings": { "ai": { @@ -104,7 +108,21 @@ "all_tagging": "Alle tags", "text_tagging": "Tekst taggen", "image_tagging": "Afbeeldingen taggen", - "tagging_rule_description": "Prompts die je hier toevoegt, worden opgenomen als regels voor het model tijdens het genereren van tags. Je kunt de uiteindelijke prompts bekijken in het promptvoorbeeldgedeelte." + "tagging_rule_description": "Prompts die je hier toevoegt, worden opgenomen als regels voor het model tijdens het genereren van tags. Je kunt de uiteindelijke prompts bekijken in het promptvoorbeeldgedeelte.", + "tag_style": "Tagstijl", + "auto_summarization_description": "Genereer automatisch samenvattingen voor je bladwijzers met behulp van AI.", + "auto_tagging": "Automatisch labelen", + "titlecase_spaces": "Hoofdletters met spaties", + "lowercase_underscores": "Kleine letters met underscores", + "inference_language": "Inferentietalen", + "titlecase_hyphens": "Hoofdletters met koppeltekens", + "lowercase_hyphens": "Kleine letters met koppeltekens", + "lowercase_spaces": "Kleine letters met spaties", + "inference_language_description": "Kies taal voor door AI gegenereerde tags en samenvattingen.", + "tag_style_description": "Kies hoe je automatisch gegenereerde tags moeten worden opgemaakt.", + "auto_tagging_description": "Genereer automatisch tags voor je bladwijzers met behulp van AI.", + "camelCase": "camelCase", + "auto_summarization": "Automatische samenvatting" }, "import": { "import_export": "Importeren / Exporteren", @@ -112,6 +130,7 @@ "import_export_bookmarks": "Importeer / Exporteer Bladwijzers", "import_bookmarks_from_html_file": "Importeer Bladwijzers van HTML bestand", "import_bookmarks_from_pocket_export": "Importeer Bladwijzers van Pocket export", + "import_bookmarks_from_matter_export": "Importeer Bladwijzers van Matter export", "import_bookmarks_from_omnivore_export": "Bladwijzers importeren uit Omnivore export", "import_bookmarks_from_linkwarden_export": "Bladwijzers importeren uit Linkwarden-export", "import_bookmarks_from_karakeep_export": "Bladwijzers importeren uit Karakeep-export", @@ -158,6 +177,49 @@ "show": "Gearchiveerde bladwijzers weergeven in tags en lijsten", "hide": "Gearchiveerde bladwijzers verbergen in tags en lijsten" } + }, + "reader_settings": { + "local_overrides_title": "Apparaatspecifieke instellingen actief", + "using_default": "Standaardinstelling van de client gebruiken", + "clear_override_hint": "Apparaatoverschrijving wissen om algemene instelling te gebruiken ({{value}})", + "font_size": "Lettergrootte", + "font_family": "Lettertypefamilie", + "preview_inline": "(voorbeeld)", + "tooltip_preview": "Niet-opgeslagen voorbeeldwijzigingen", + "save_to_all_devices": "Alle apparaten", + "tooltip_local": "Apparaatinstellingen verschillen van algemene instellingen", + "reset_preview": "Voorbeeld resetten", + "mono": "Monospace", + "line_height": "Regelhoogte", + "tooltip_default": "Leesinstellingen", + "title": "Lezerinstellingen", + "serif": "Serif", + "preview": "Voorbeeld", + "not_set": "Niet ingesteld", + "clear_local_overrides": "Apparaatinstellingen wissen", + "preview_text": "The quick brown fox jumps over the lazy dog. Zo ziet de tekst in je lezerweergave eruit.", + "local_overrides_cleared": "Apparaatspecifieke instellingen zijn gewist", + "local_overrides_description": "Dit apparaat heeft lezerinstellingen die afwijken van je globale standaardwaarden:", + "clear_defaults": "Alle standaarden wissen", + "description": "Configureer de standaard tekstinstellingen voor de lezerweergave. Deze instellingen worden gesynchroniseerd op al je apparaten.", + "defaults_cleared": "Standaardwaarden van de lezer zijn gewist", + "save_hint": "Instellingen opslaan alleen voor dit apparaat of synchroniseren op alle apparaten", + "save_as_default": "Opslaan als standaard", + "save_to_device": "Dit apparaat", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Niet-opgeslagen voorbeeldwijzigingen; apparaatinstellingen verschillen van algemene instellingen", + "adjust_hint": "Pas de bovenstaande instellingen aan om een voorbeeld van de wijzigingen te bekijken" + }, + "avatar": { + "upload": "Avatar uploaden", + "change": "Avatar wijzigen", + "remove_confirm_title": "Avatar verwijderen?", + "updated": "Avatar bijgewerkt", + "removed": "Avatar verwijderd", + "description": "Upload een vierkante afbeelding om als je avatar te gebruiken.", + "remove_confirm_description": "Hiermee verwijder je je huidige profielfoto.", + "title": "Profielfoto", + "remove": "Avatar verwijderen" } }, "back_to_app": "Terug Naar App", @@ -556,7 +618,8 @@ "tabs": { "content": "Inhoud", "details": "Details" - } + }, + "archive_info": "Archieven worden mogelijk niet correct inline weergegeven als ze Javascript vereisen. Voor de beste resultaten kun je het <1>downloaden en openen in je browser</1>." }, "editor": { "text_toolbar": { @@ -745,7 +808,14 @@ "year_s_ago": " Jaar geleden", "history": "Recente zoekopdrachten", "title_contains": "Titel bevat", - "title_does_not_contain": "Titel bevat niet" + "title_does_not_contain": "Titel bevat niet", + "is_broken_link": "Heeft een verbroken link", + "tags": "Labels", + "no_suggestions": "Geen suggesties", + "filters": "Filters", + "is_not_broken_link": "Heeft een werkende link", + "lists": "Lijsten", + "feeds": "Feeds" }, "dialogs": { "bookmarks": { @@ -760,7 +830,8 @@ "updated": "De bladwijzer is bijgewerkt!", "deleted": "De bladwijzer is verwijderd!", "delete_from_list": "De bladwijzer is uit de lijst verwijderd", - "clipboard_copied": "Link is naar je klembord gekopieerd!" + "clipboard_copied": "Link is naar je klembord gekopieerd!", + "preserve_pdf": "PDF-opslag is geactiveerd" }, "lists": { "updated": "Lijst is bijgewerkt!", diff --git a/apps/web/lib/i18n/locales/pl/translation.json b/apps/web/lib/i18n/locales/pl/translation.json index e82e8921..8cb621e7 100644 --- a/apps/web/lib/i18n/locales/pl/translation.json +++ b/apps/web/lib/i18n/locales/pl/translation.json @@ -39,7 +39,9 @@ "summary": "Podsumowanie", "quota": "Limit", "bookmarks": "Zakładki", - "storage": "Miejsce na dane" + "storage": "Miejsce na dane", + "pdf": "Zarchiwizowane PDF", + "default": "Domyślne" }, "actions": { "remove_from_list": "Usuń z listy", @@ -84,7 +86,9 @@ "confirm": "Potwierdź", "regenerate": "Wygeneruj ponownie", "load_more": "Załaduj więcej", - "edit_notes": "Edytuj notatki" + "edit_notes": "Edytuj notatki", + "preserve_as_pdf": "Zachowaj jako PDF", + "offline_copies": "Kopie offline" }, "settings": { "info": { @@ -108,11 +112,55 @@ "open_external_url": "Otwórz oryginalny URL", "open_bookmark_details": "Otwórz szczegóły zakładki" } + }, + "reader_settings": { + "local_overrides_title": "Aktywne ustawienia specyficzne dla urządzenia", + "using_default": "Użyj ustawień domyślnych klienta", + "clear_override_hint": "Wyczyść ustawienia urządzenia, aby użyć ustawień globalnych ({{value}})", + "font_size": "Rozmiar czcionki", + "font_family": "Rodzina czcionek", + "preview_inline": "(podgląd)", + "tooltip_preview": "Niezapisane zmiany podglądu", + "save_to_all_devices": "Wszystkie urządzenia", + "tooltip_local": "Ustawienia urządzenia różnią się od globalnych", + "reset_preview": "Zresetuj podgląd", + "mono": "Monospace", + "line_height": "Wysokość linii", + "tooltip_default": "Ustawienia czytania", + "title": "Ustawienia czytnika", + "serif": "Z szeryfami", + "preview": "Podgląd", + "not_set": "Nie ustawiono", + "clear_local_overrides": "Wyczyść ustawienia urządzenia", + "preview_text": "The quick brown fox jumps over the lazy dog. Tak będzie wyglądał tekst w widoku czytnika.", + "local_overrides_cleared": "Ustawienia specyficzne dla urządzenia zostały wyczyszczone", + "local_overrides_description": "To urządzenie ma ustawienia czytnika, które różnią się od globalnych ustawień domyślnych:", + "clear_defaults": "Wyczyść wszystkie ustawienia domyślne", + "description": "Skonfiguruj domyślne ustawienia tekstu dla widoku czytnika. Ustawienia te synchronizują się na wszystkich Twoich urządzeniach.", + "defaults_cleared": "Ustawienia domyślne czytnika zostały wyczyszczone", + "save_hint": "Zapisz ustawienia tylko dla tego urządzenia lub synchronizuj na wszystkich urządzeniach", + "save_as_default": "Zapisz jako domyślne", + "save_to_device": "To urządzenie", + "sans": "Bezszeryfowa", + "tooltip_preview_and_local": "Nie zapisano zmian w podglądzie; ustawienia urządzenia różnią się od globalnych", + "adjust_hint": "Dostosuj powyższe ustawienia, aby wyświetlić zmiany w podglądzie" + }, + "avatar": { + "upload": "Wrzuć awatar", + "change": "Zmień awatar", + "remove_confirm_title": "Usunąć awatar?", + "updated": "Awatar zaktualizowany", + "removed": "Awatar usunięty", + "description": "Wrzuć kwadratowy obrazek, który będzie Twoim awatarem.", + "remove_confirm_description": "To wyczyści Twoje aktualne zdjęcie profilowe.", + "title": "Zdjęcie profilowe", + "remove": "Usuń awatar" } }, "import": { "import_bookmarks_from_html_file": "Importuj zakładki z pliku HTML", "import_bookmarks_from_pocket_export": "Importuj zakładki z eksportu Pocket", + "import_bookmarks_from_matter_export": "Importuj zakładki z eksportu Matter", "import_export": "Import / Eksport", "import_export_bookmarks": "Import / Eksport zakładek", "import_bookmarks_from_omnivore_export": "Importuj zakładki z eksportu Omnivore", @@ -136,7 +184,21 @@ "summarization_prompt": "Monit o podsumowanie", "all_tagging": "Wszystkie tagi", "text_tagging": "Tagowanie tekstu", - "image_tagging": "Tagowanie obrazów" + "image_tagging": "Tagowanie obrazów", + "tag_style": "Styl tagów", + "auto_summarization_description": "Automatycznie generuj streszczenia dla zakładek za pomocą AI.", + "auto_tagging": "Automatyczne tagowanie", + "titlecase_spaces": "Wielkie litery ze spacjami", + "lowercase_underscores": "Małe litery z podkreślnikami", + "inference_language": "Język wnioskowania", + "titlecase_hyphens": "Wielkie litery z myślnikami", + "lowercase_hyphens": "Małe litery z myślnikami", + "lowercase_spaces": "Małe litery ze spacjami", + "inference_language_description": "Wybierz język dla tagów i podsumowań generowanych przez AI.", + "tag_style_description": "Wybierz, jak powinny być formatowane autogenerowane tagi.", + "auto_tagging_description": "Automatycznie generuj tagi dla zakładek za pomocą AI.", + "camelCase": "camelCase", + "auto_summarization": "Automatyczne podsumowywanie" }, "feeds": { "rss_subscriptions": "Subskrypcje RSS", @@ -622,7 +684,8 @@ "refetch": "Pobieranie ponownie zostało zaplanowane!", "full_page_archive": "Tworzenie pełnego archiwum strony zostało rozpoczęte", "delete_from_list": "Zakładka została usunięta z listy", - "clipboard_copied": "Link został skopiowany do schowka!" + "clipboard_copied": "Link został skopiowany do schowka!", + "preserve_pdf": "Zapis PDF został uruchomiony" }, "tags": { "created": "Etykieta została utworzona!", @@ -732,7 +795,8 @@ "tabs": { "content": "Treść", "details": "Szczegóły" - } + }, + "archive_info": "Archiwa mogą się nie wyświetlać poprawnie w wierszu, jeśli wymagają Javascript. Dla najlepszych rezultatów, <1>pobierz i otwórz w przeglądarce</1>." }, "highlights": { "no_highlights": "Nie masz jeszcze żadnych wyróżnień." @@ -775,7 +839,14 @@ "year_s_ago": " Lat(a) temu", "history": "Ostatnie wyszukiwania", "title_contains": "Tytuł zawiera", - "title_does_not_contain": "Tytuł nie zawiera" + "title_does_not_contain": "Tytuł nie zawiera", + "is_broken_link": "Ma Zepsuty Link", + "tags": "Tagi", + "no_suggestions": "Brak propozycji", + "filters": "Filtry", + "is_not_broken_link": "Ma Działający Link", + "lists": "Listy", + "feeds": "Kanały" }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/pt/translation.json b/apps/web/lib/i18n/locales/pt/translation.json index a154726c..7bf1ccae 100644 --- a/apps/web/lib/i18n/locales/pt/translation.json +++ b/apps/web/lib/i18n/locales/pt/translation.json @@ -39,7 +39,9 @@ "summary": "Resumo", "quota": "Quota", "bookmarks": "Favoritos", - "storage": "Armazenamento" + "storage": "Armazenamento", + "pdf": "PDF arquivado", + "default": "Padrão" }, "actions": { "close": "Fechar", @@ -84,7 +86,9 @@ "confirm": "Confirmar", "regenerate": "Regenerar", "load_more": "Carregar mais", - "edit_notes": "Editar notas" + "edit_notes": "Editar notas", + "preserve_as_pdf": "Preservar como PDF", + "offline_copies": "Cópias offline" }, "settings": { "webhooks": { @@ -107,6 +111,7 @@ }, "import": { "import_bookmarks_from_pocket_export": "Importar marcadores da exportação do Pocket", + "import_bookmarks_from_matter_export": "Importar marcadores da exportação do Matter", "import_bookmarks_from_omnivore_export": "Importar marcadores da exportação do Omnivore", "import_export": "Importar / Exportar", "import_export_bookmarks": "Importar/Exportar Marcadores", @@ -139,6 +144,49 @@ "show": "Mostrar marcadores arquivados em tags e listas", "hide": "Ocultar marcadores arquivados em tags e listas" } + }, + "reader_settings": { + "local_overrides_title": "Configurações específicas do dispositivo ativas", + "using_default": "Usando o padrão do cliente", + "clear_override_hint": "Limpar a substituição do dispositivo para usar a configuração global ({{value}})", + "font_size": "Tamanho da fonte", + "font_family": "Família da fonte", + "preview_inline": "(visualização)", + "tooltip_preview": "Alterações não salvas na pré-visualização", + "save_to_all_devices": "Todos os dispositivos", + "tooltip_local": "Configurações do dispositivo são diferentes das globais", + "reset_preview": "Redefinir pré-visualização", + "mono": "Monoespaçada", + "line_height": "Altura da linha", + "tooltip_default": "Configurações de leitura", + "title": "Configurações do Leitor", + "serif": "Com serifa", + "preview": "Pré-visualização", + "not_set": "Não definido", + "clear_local_overrides": "Limpar configurações do dispositivo", + "preview_text": "A raposa marrom rápida pula sobre o cachorro preguiçoso. É assim que o texto da visualização do leitor será exibido.", + "local_overrides_cleared": "As configurações específicas do dispositivo foram apagadas", + "local_overrides_description": "Este dispositivo tem configurações de leitor que diferem de suas configurações padrão globais:", + "clear_defaults": "Limpar todos os padrões", + "description": "Configure as configurações de texto padrão para a visualização do leitor. Essas configurações são sincronizadas em todos os seus dispositivos.", + "defaults_cleared": "Os padrões do leitor foram apagados", + "save_hint": "Salvar configurações apenas para este dispositivo ou sincronizar entre todos os dispositivos", + "save_as_default": "Salvar como padrão", + "save_to_device": "Este dispositivo", + "sans": "Sem serifa", + "tooltip_preview_and_local": "Alterações não salvas na pré-visualização; as configurações do dispositivo são diferentes das globais", + "adjust_hint": "Ajuste as configurações acima para visualizar as alterações" + }, + "avatar": { + "upload": "Mandar avatar", + "change": "Trocar avatar", + "remove_confirm_title": "Remover avatar?", + "updated": "Avatar atualizado", + "removed": "Avatar removido", + "description": "Manda uma imagem quadrada para usar como teu avatar.", + "remove_confirm_description": "Isso vai apagar tua foto de perfil atual.", + "title": "Foto do perfil", + "remove": "Remover avatar" } }, "ai": { @@ -152,7 +200,21 @@ "text_tagging": "Marcação de texto", "image_tagging": "Marcação de imagem", "summarization": "Sumarização", - "ai_settings": "Configurações de IA" + "ai_settings": "Configurações de IA", + "tag_style": "Estilo da etiqueta", + "auto_summarization_description": "Gerar automaticamente resumos para seus favoritos usando IA.", + "auto_tagging": "Marcação automática", + "titlecase_spaces": "Maiúsculas e minúsculas com espaços", + "lowercase_underscores": "Minúsculas com underscores", + "inference_language": "Linguagem de Inferência", + "titlecase_hyphens": "Maiúsculas e minúsculas com hífens", + "lowercase_hyphens": "Minúsculas com hífens", + "lowercase_spaces": "Minúsculas com espaços", + "inference_language_description": "Escolha o idioma para as tags e resumos gerados por IA.", + "tag_style_description": "Escolha como as suas etiquetas geradas automaticamente devem ser formatadas.", + "auto_tagging_description": "Gerar automaticamente tags para seus favoritos usando IA.", + "camelCase": "camelCase", + "auto_summarization": "Resumo automático" }, "api_keys": { "new_api_key": "Nova chave da API", @@ -671,7 +733,14 @@ "year_s_ago": " Ano(s) atrás", "history": "Pesquisas recentes", "title_contains": "O título contém…", - "title_does_not_contain": "O título não contém…" + "title_does_not_contain": "O título não contém…", + "is_broken_link": "Tem link quebrado", + "tags": "Etiquetas", + "no_suggestions": "Sem sugestões", + "filters": "Filtros", + "is_not_broken_link": "Tem link funcionando", + "lists": "Listas", + "feeds": "Feeds" }, "preview": { "cached_content": "Conteúdo em cache", @@ -680,7 +749,8 @@ "tabs": { "content": "Conteúdo", "details": "Detalhes" - } + }, + "archive_info": "Os arquivos podem não ser renderizados corretamente embutidos se exigirem Javascript. Para obter melhores resultados, <1>baixe-o e abra-o no seu navegador</1>." }, "editor": { "new_item": "NOVO ITEM", @@ -748,7 +818,8 @@ "clipboard_copied": "Link foi adicionado à sua área de transferência!", "updated": "O marcador foi atualizado!", "deleted": "O marcador foi excluído!", - "refetch": "A nova busca foi enfileirada!" + "refetch": "A nova busca foi enfileirada!", + "preserve_pdf": "A preservação em PDF foi acionada" }, "lists": { "updated": "A lista foi atualizada!", diff --git a/apps/web/lib/i18n/locales/pt_BR/translation.json b/apps/web/lib/i18n/locales/pt_BR/translation.json index 881c9783..2d1a7f8a 100644 --- a/apps/web/lib/i18n/locales/pt_BR/translation.json +++ b/apps/web/lib/i18n/locales/pt_BR/translation.json @@ -39,7 +39,9 @@ "summary": "Resumo", "quota": "Cota", "bookmarks": "Favoritos", - "storage": "Armazenamento" + "storage": "Armazenamento", + "pdf": "PDF Arquivado", + "default": "Padrão" }, "actions": { "unarchive": "Desarquivar", @@ -84,7 +86,9 @@ "confirm": "Confirmar", "regenerate": "Regenerar", "load_more": "Carregar mais", - "edit_notes": "Editar notas" + "edit_notes": "Editar notas", + "preserve_as_pdf": "Preservar como PDF", + "offline_copies": "Cópias Offline" }, "settings": { "info": { @@ -108,6 +112,49 @@ "open_external_url": "Abrir URL original", "open_bookmark_details": "Abrir detalhes do favorito" } + }, + "reader_settings": { + "local_overrides_title": "Configurações específicas do dispositivo ativas", + "using_default": "Usando o padrão do cliente", + "clear_override_hint": "Limpar substituição do dispositivo para usar a configuração global ({{value}})", + "font_size": "Tamanho da Fonte", + "font_family": "Família da Fonte", + "preview_inline": "(visualização)", + "tooltip_preview": "Alterações de visualização não salvas", + "save_to_all_devices": "Todos os dispositivos", + "tooltip_local": "Configurações do dispositivo diferentes das globais", + "reset_preview": "Redefinir visualização", + "mono": "Monoespaçado", + "line_height": "Altura da Linha", + "tooltip_default": "Configurações de leitura", + "title": "Configurações do Leitor", + "serif": "Serifa", + "preview": "Visualização", + "not_set": "Não definido", + "clear_local_overrides": "Limpar configurações do dispositivo", + "preview_text": "A raposa marrom rápida pula sobre o cão preguiçoso. É assim que o texto da sua visualização do leitor aparecerá.", + "local_overrides_cleared": "As configurações específicas do dispositivo foram apagadas", + "local_overrides_description": "Este dispositivo tem configurações de leitor que diferem de suas configurações padrão globais:", + "clear_defaults": "Limpar todos os padrões", + "description": "Configure as configurações de texto padrão para a visualização do leitor. Essas configurações são sincronizadas em todos os seus dispositivos.", + "defaults_cleared": "Os padrões do leitor foram apagados", + "save_hint": "Salvar configurações apenas para este dispositivo ou sincronizar entre todos os dispositivos", + "save_as_default": "Salvar como padrão", + "save_to_device": "Este dispositivo", + "sans": "Sem serifa", + "tooltip_preview_and_local": "Alterações de visualização não salvas; configurações do dispositivo diferentes das globais", + "adjust_hint": "Ajuste as configurações acima para visualizar as alterações" + }, + "avatar": { + "upload": "Enviar avatar", + "change": "Mudar avatar", + "remove_confirm_title": "Remover avatar?", + "updated": "Avatar atualizado", + "removed": "Avatar removido", + "description": "Envie uma imagem quadrada para usar como seu avatar.", + "remove_confirm_description": "Isso vai apagar a foto do seu perfil atual.", + "title": "Foto do perfil", + "remove": "Remover avatar" } }, "back_to_app": "Voltar ao App", @@ -123,7 +170,21 @@ "all_tagging": "Todas as Tags", "text_tagging": "Tags de Texto", "image_tagging": "Tags de Imagem", - "summarization": "Resumo" + "summarization": "Resumo", + "tag_style": "Estilo da etiqueta", + "auto_summarization_description": "Gere automaticamente resumos para seus favoritos usando IA.", + "auto_tagging": "Marcação automática", + "titlecase_spaces": "Maiúsculas e minúsculas com espaços", + "lowercase_underscores": "Minúsculas com sublinhados", + "inference_language": "Linguagem de inferência", + "titlecase_hyphens": "Maiúsculas e minúsculas com hífens", + "lowercase_hyphens": "Minúsculas com hífens", + "lowercase_spaces": "Minúsculas com espaços", + "inference_language_description": "Escolha o idioma para tags e resumos gerados por IA.", + "tag_style_description": "Escolha como suas tags auto-geradas devem ser formatadas.", + "auto_tagging_description": "Gere automaticamente tags para seus favoritos usando IA.", + "camelCase": "camelCase", + "auto_summarization": "Resumo automático" }, "feeds": { "rss_subscriptions": "Assinaturas de RSS", @@ -154,6 +215,7 @@ "import_export_bookmarks": "Importar / Exportar Favoritos", "import_bookmarks_from_html_file": "Importar Favoritos de arquivo HTML", "import_bookmarks_from_pocket_export": "Importar Favoritos de exportação do Pocket", + "import_bookmarks_from_matter_export": "Importar Favoritos de exportação do Matter", "import_bookmarks_from_omnivore_export": "Importar Favoritos de exportação do Omnivore", "import_bookmarks_from_linkwarden_export": "Importar Favoritos de exportação do Linkwarden", "import_bookmarks_from_karakeep_export": "Importar Favoritos de exportação do Karakeep", @@ -680,7 +742,14 @@ "year_s_ago": " Ano(s) atrás", "history": "Pesquisas recentes", "title_contains": "Título Contém", - "title_does_not_contain": "Título Não Contém" + "title_does_not_contain": "Título Não Contém", + "is_broken_link": "Possui link quebrado", + "tags": "Tags", + "no_suggestions": "Sem sugestões", + "filters": "Filtros", + "is_not_broken_link": "Possui link funcionando", + "lists": "Listas", + "feeds": "Feeds" }, "preview": { "view_original": "Ver Original", @@ -689,7 +758,8 @@ "tabs": { "content": "Conteúdo", "details": "Detalhes" - } + }, + "archive_info": "Arquivos podem não renderizar corretamente embutidos se eles exigirem Javascript. Para obter melhores resultados, <1>baixe-o e abra-o no seu navegador</1>." }, "editor": { "quickly_focus": "Você pode acessar rapidamente este campo pressionando ⌘ + E", @@ -763,7 +833,8 @@ "refetch": "A nova busca foi enfileirada!", "full_page_archive": "A criação do arquivo de página inteira foi acionada", "delete_from_list": "O favorito foi excluído da lista", - "clipboard_copied": "O link foi adicionado à sua área de transferência!" + "clipboard_copied": "O link foi adicionado à sua área de transferência!", + "preserve_pdf": "A preservação em PDF foi acionada" }, "lists": { "created": "A lista foi criada!", diff --git a/apps/web/lib/i18n/locales/ru/translation.json b/apps/web/lib/i18n/locales/ru/translation.json index 05a82088..f3da6169 100644 --- a/apps/web/lib/i18n/locales/ru/translation.json +++ b/apps/web/lib/i18n/locales/ru/translation.json @@ -39,7 +39,9 @@ "summary": "Краткое содержание", "quota": "Квота", "bookmarks": "Закладки", - "storage": "Хранилище" + "storage": "Хранилище", + "pdf": "Архивированный PDF", + "default": "По умолчанию" }, "lists": { "new_list": "Новый список", @@ -150,6 +152,49 @@ "title": "Архивированные закладки", "show": "Показывать архивированные закладки в тегах и списках" } + }, + "reader_settings": { + "local_overrides_title": "Активны настройки для этого устройства", + "using_default": "Используются настройки клиента по умолчанию", + "clear_override_hint": "Удалите переопределение устройства, чтобы использовать глобальную настройку ({{value}})", + "font_size": "Размер шрифта", + "font_family": "Тип шрифта", + "preview_inline": "(предпросмотр)", + "tooltip_preview": "Несохраненные изменения предпросмотра", + "save_to_all_devices": "Все устройства", + "tooltip_local": "Настройки устройства отличаются от глобальных", + "reset_preview": "Сбросить предпросмотр", + "mono": "Моноширинный", + "line_height": "Высота строки", + "tooltip_default": "Настройки чтения", + "title": "Настройки читалки", + "serif": "С засечками", + "preview": "Предварительный просмотр", + "not_set": "Не задано", + "clear_local_overrides": "Сбросить настройки для устройства", + "preview_text": "Шустрая бурая лиса перепрыгивает ленивого пса. Вот так будет выглядеть текст в режиме чтения.", + "local_overrides_cleared": "Настройки для устройства сброшены", + "local_overrides_description": "На этом устройстве параметры читалки отличаются от ваших глобальных настроек:", + "clear_defaults": "Сбросить все значения по умолчанию", + "description": "Настройте параметры текста по умолчанию для режима чтения. Эти параметры синхронизируются на всех ваших устройствах.", + "defaults_cleared": "Настройки читалки по умолчанию сброшены", + "save_hint": "Сохранить настройки только для этого устройства или синхронизировать на всех устройствах", + "save_as_default": "Сохранить как значения по умолчанию", + "save_to_device": "Это устройство", + "sans": "Без засечек", + "tooltip_preview_and_local": "Несохраненные изменения предпросмотра; настройки устройства отличаются от глобальных", + "adjust_hint": "Отрегулируйте настройки выше, чтобы просмотреть изменения" + }, + "avatar": { + "upload": "Загрузить аватар", + "change": "Сменить аватар", + "remove_confirm_title": "Удалить аватар?", + "updated": "Аватар обновлён", + "removed": "Аватар удалён", + "description": "Загрузи квадратное изображение, которое будет твоим аватаром.", + "remove_confirm_description": "Текущее фото профиля будет удалено.", + "title": "Фото профиля", + "remove": "Удалить аватар" } }, "import": { @@ -157,13 +202,14 @@ "import_export": "Импорт / Экспорт", "import_export_bookmarks": "Импорт / Экспорт закладок", "import_bookmarks_from_pocket_export": "Импортировать закладки из экспорта Pocket", + "import_bookmarks_from_matter_export": "Импортировать закладки из экспорта Matter", "import_bookmarks_from_omnivore_export": "Импортировать закладки из экспорта Omnivore", "imported_bookmarks": "Импортировано закладок", "import_bookmarks_from_html_file": "Импортировать закладки из HTML файла", "export_links_and_notes": "Экспортировать ссылки и заметки", "import_bookmarks_from_linkwarden_export": "Импортировать закладки из экспорта Linkwarden", "import_bookmarks_from_tab_session_manager_export": "Импортировать закладки из Tab Session Manager", - "import_bookmarks_from_mymind_export": "Импортируй закладки из экспорта mymind." + "import_bookmarks_from_mymind_export": "Импортировать закладки из экспорта mymind" }, "api_keys": { "key_success": "Ключ был успешно создан", @@ -188,7 +234,21 @@ "image_tagging": "Пометка изображений тегами", "summarization": "Суммирование", "summarization_prompt": "Подсказка для суммирования", - "all_tagging": "Все теги" + "all_tagging": "Все теги", + "tag_style": "Стиль тегов", + "auto_summarization_description": "Автоматически генерируйте сводки для своих закладок с помощью ИИ.", + "auto_tagging": "Автоматическая расстановка тегов", + "titlecase_spaces": "Заглавные с пробелами", + "lowercase_underscores": "Строчные с подчеркиваниями", + "inference_language": "Язык логического вывода", + "titlecase_hyphens": "Заглавные с дефисами", + "lowercase_hyphens": "Строчные с дефисами", + "lowercase_spaces": "Строчные с пробелами", + "inference_language_description": "Выбери язык для тегов и саммари, которые генерит ИИ.", + "tag_style_description": "Выбери, как форматировать автосгенерированные теги.", + "auto_tagging_description": "Автоматически генерируйте теги для ваших закладок с помощью ИИ.", + "camelCase": "camelCase", + "auto_summarization": "Автоматическое создание сводок" }, "feeds": { "rss_subscriptions": "RSS подписки", @@ -473,7 +533,9 @@ "confirm": "Подтвердить", "regenerate": "Обновить", "load_more": "Загрузить еще", - "edit_notes": "Редактировать заметки" + "edit_notes": "Редактировать заметки", + "preserve_as_pdf": "Сохранить как PDF", + "offline_copies": "Автономные копии" }, "editor": { "text_toolbar": { @@ -705,7 +767,8 @@ "tabs": { "content": "Содержание", "details": "Подробности" - } + }, + "archive_info": "Архивы могут неправильно отображаться во встроенном режиме, если для них требуется Javascript. Для достижения наилучших результатов <1>загрузите их и откройте в браузере</1>." }, "toasts": { "bookmarks": { @@ -714,7 +777,8 @@ "delete_from_list": "Закладка была удалена из списка", "clipboard_copied": "Ссылка была скопирована в буфер обмена!", "deleted": "Закладка была удалена!", - "updated": "Закладка была обновлена!" + "updated": "Закладка была обновлена!", + "preserve_pdf": "Сохранение в формате PDF было запущено" }, "lists": { "created": "Список был создан!", @@ -772,12 +836,19 @@ "year_s_ago": " Год(а) назад", "history": "Недавние поиски", "title_contains": "Содержит в заголовке", - "title_does_not_contain": "Не содержит в заголовке" + "title_does_not_contain": "Не содержит в заголовке", + "is_broken_link": "Битые ссылки", + "tags": "Теги", + "no_suggestions": "Нет предложений", + "filters": "Фильтры", + "is_not_broken_link": "Рабочие ссылки", + "lists": "Списки", + "feeds": "Ленты" }, "dialogs": { "bookmarks": { "delete_confirmation_title": "Удалить закладку?", - "delete_confirmation_description": "Ты уверен, что хочешь удалить эту закладку?" + "delete_confirmation_description": "Вы уверены, что хотите удалить эту закладку?" } }, "highlights": { diff --git a/apps/web/lib/i18n/locales/sk/translation.json b/apps/web/lib/i18n/locales/sk/translation.json index 4fbcb06b..00196d26 100644 --- a/apps/web/lib/i18n/locales/sk/translation.json +++ b/apps/web/lib/i18n/locales/sk/translation.json @@ -39,7 +39,9 @@ "summary": "Zhrnutie", "quota": "Kvóta", "bookmarks": "Záložky", - "storage": "Úložisko" + "storage": "Úložisko", + "pdf": "Archivované PDF", + "default": "Predvolené" }, "actions": { "cancel": "Zrušiť", @@ -84,7 +86,9 @@ "confirm": "Potvrdiť", "regenerate": "Obnoviť", "load_more": "Načítať viac", - "edit_notes": "Upraviť poznámky" + "edit_notes": "Upraviť poznámky", + "preserve_as_pdf": "Uložiť ako PDF", + "offline_copies": "Offline kópie" }, "lists": { "favourites": "Obľúbené", @@ -178,6 +182,7 @@ "import_export_bookmarks": "Importovať / exportovať záložky", "import_bookmarks_from_html_file": "Importovať záložky z HTML súboru", "import_bookmarks_from_pocket_export": "Importovať záložky z Pocket exportu", + "import_bookmarks_from_matter_export": "Importovať záložky z Matter exportu", "import_bookmarks_from_linkwarden_export": "Importovať záložky z Linkwarden exportu", "import_bookmarks_from_karakeep_export": "Importovať záložky z Karakeep exportu", "export_links_and_notes": "Exportovať odkazy a poznámky", @@ -209,6 +214,49 @@ "hide": "Skryť archivované záložky v tagoch a zoznamoch", "show": "Zobraziť archivované záložky v tagoch a zoznamoch" } + }, + "reader_settings": { + "local_overrides_title": "Sú aktívne nastavenia špecifické pre zariadenie", + "using_default": "Používa sa predvolené nastavenie pre klienta", + "clear_override_hint": "Vymažte prepísanie zariadenia a použite globálne nastavenie ({{value}})", + "font_size": "Veľkosť písma", + "font_family": "Rodina písma", + "preview_inline": "(Náhľad)", + "tooltip_preview": "Neuložené zmeny ukážky", + "save_to_all_devices": "Všetky zariadenia", + "tooltip_local": "Nastavenia zariadenia sa líšia od globálnych", + "reset_preview": "Resetovať ukážku", + "mono": "Neproporcionálne", + "line_height": "Výška riadku", + "tooltip_default": "Nastavenia čítania", + "title": "Nastavenia čítačky", + "serif": "Pätkové", + "preview": "Náhľad", + "not_set": "Nenastavené", + "clear_local_overrides": "Vymazať nastavenia zariadenia", + "preview_text": "The quick brown fox jumps over the lazy dog. Takto sa bude zobrazovať text v režime čítačky.", + "local_overrides_cleared": "Nastavenia špecifické pre zariadenie boli vymazané", + "local_overrides_description": "Toto zariadenie má nastavenia čítačky, ktoré sa líšia od tvojich globálnych predvolených nastavení:", + "clear_defaults": "Vymazať všetky predvolené", + "description": "Konfigurácia predvolených nastavení textu pre zobrazenie čítačky. Tieto nastavenia sa synchronizujú medzi všetkými tvojimi zariadeniami.", + "defaults_cleared": "Predvolené nastavenia čítačky boli vymazané", + "save_hint": "Uložte nastavenia iba pre toto zariadenie alebo ich synchronizujte na všetkých zariadeniach", + "save_as_default": "Uložiť ako predvolené", + "save_to_device": "Toto zariadenie", + "sans": "Bez pätiek", + "tooltip_preview_and_local": "Neuložené zmeny ukážky; nastavenia zariadenia sa líšia od globálnych", + "adjust_hint": "Upravte nastavenia vyššie, aby ste si prezreli zmeny" + }, + "avatar": { + "upload": "Nahrať avatara", + "change": "Zmeniť avatara", + "remove_confirm_title": "Odstrániť avatara?", + "updated": "Avatar aktualizovaný", + "removed": "Avatar odstránený", + "description": "Nahraj štvorcový obrázok, ktorý sa použije ako tvoj avatar.", + "remove_confirm_description": "Týmto sa vymaže tvoja aktuálna profilová fotka.", + "title": "Profilová fotka", + "remove": "Odstrániť avatara" } }, "ai": { @@ -222,7 +270,21 @@ "image_tagging": "Označovanie obrázkov", "summarization": "Zhrnutie", "images_prompt": "Výzva obrázka", - "summarization_prompt": "Výzva na sumarizáciu" + "summarization_prompt": "Výzva na sumarizáciu", + "tag_style": "Štýl tagov", + "auto_summarization_description": "Automaticky generujte zhrnutia pre vaše záložky pomocou AI.", + "auto_tagging": "Automatické označovanie štítkami", + "titlecase_spaces": "Veľké začiatočné písmená s medzerami", + "lowercase_underscores": "Malé písmená s podčiarkovníkmi", + "inference_language": "Jazyk inferencie", + "titlecase_hyphens": "Veľké začiatočné písmená s pomlčkami", + "lowercase_hyphens": "Malé písmená s pomlčkami", + "lowercase_spaces": "Malé písmená s medzerami", + "inference_language_description": "Vyber jazyk pre tagy a súhrny generované AI.", + "tag_style_description": "Vyber si, ako majú byť formátované automaticky generované tagy.", + "auto_tagging_description": "Automaticky generujte štítky pre vaše záložky pomocou AI.", + "camelCase": "camelCase", + "auto_summarization": "Automatické zhrnutie" }, "webhooks": { "add_auth_token": "Pridať autorizačný token", @@ -513,7 +575,14 @@ "year_s_ago": " Rok(y) dozadu", "history": "Nedávne vyhľadávania", "title_contains": "Názov obsahuje", - "title_does_not_contain": "Názov neobsahuje" + "title_does_not_contain": "Názov neobsahuje", + "is_broken_link": "Má nefunkčný odkaz", + "tags": "Značky", + "no_suggestions": "Žiadne návrhy", + "filters": "Filtre", + "is_not_broken_link": "Má funkčný odkaz", + "lists": "Zoznamy", + "feeds": "Kanály" }, "layouts": { "masonry": "Dlaždice", @@ -745,7 +814,8 @@ "tabs": { "content": "Obsah", "details": "Podrobnosti" - } + }, + "archive_info": "Archívy sa nemusia vykresľovať správne priamo, ak vyžadujú Javascript. Pre dosiahnutie najlepších výsledkov si ich <1>stiahni a otvor v prehliadači</1>." }, "toasts": { "bookmarks": { @@ -754,7 +824,8 @@ "delete_from_list": "Záložka bola odstránená zo zoznamu", "deleted": "Záložka bola zmazaná!", "refetch": "Opätovné načítanie bolo zaradené do frontu!", - "full_page_archive": "Bolo spustené vytváranie archívu celej stránky" + "full_page_archive": "Bolo spustené vytváranie archívu celej stránky", + "preserve_pdf": "Ukladanie do PDF bolo spustené" }, "lists": { "updated": "Zoznam bol aktualizovaný!", diff --git a/apps/web/lib/i18n/locales/sl/translation.json b/apps/web/lib/i18n/locales/sl/translation.json index 671f34ca..8b99a153 100644 --- a/apps/web/lib/i18n/locales/sl/translation.json +++ b/apps/web/lib/i18n/locales/sl/translation.json @@ -17,6 +17,7 @@ "import_bookmarks_from_linkwarden_export": "Uvozi zaznamke iz Linkwarden izvoza", "imported_bookmarks": "Uvoženi zaznamki", "import_bookmarks_from_pocket_export": "Uvozi zaznamke iz Pocket izvoza", + "import_bookmarks_from_matter_export": "Uvozi zaznamke iz Matter izvoza", "import_export_bookmarks": "Uvoz / Izvoz zaznamkov", "import_bookmarks_from_omnivore_export": "Uvozi zaznamke iz Omnivore izvoza", "export_links_and_notes": "Izvozi povezave in zapiske", @@ -46,6 +47,49 @@ "show": "Prikaži arhivirane zaznamke v oznakah in seznamih", "hide": "Skrij arhivirane zaznamke v oznakah in seznamih" } + }, + "reader_settings": { + "local_overrides_title": "Aktivne nastavitve, specifične za napravo", + "using_default": "Uporaba privzete vrednosti odjemalca", + "clear_override_hint": "Počisti preglasitev naprave, da uporabiš globalno nastavitev ({{value}})", + "font_size": "Velikost pisave", + "font_family": "Družina pisav", + "preview_inline": "(predogled)", + "tooltip_preview": "Neshranjene spremembe predogleda", + "save_to_all_devices": "Vse naprave", + "tooltip_local": "Nastavitve naprave se razlikujejo od globalnih", + "reset_preview": "Ponastavi predogled", + "mono": "Enoprostorska", + "line_height": "Višina vrstice", + "tooltip_default": "Nastavitve branja", + "title": "Nastavitve bralnika", + "serif": "Serif", + "preview": "Predogled", + "not_set": "Ni nastavljeno", + "clear_local_overrides": "Počisti nastavitve naprave", + "preview_text": "Rjava lisica skoči čez lenega psa. Tako bo videti vaše besedilo v pogledu bralnika.", + "local_overrides_cleared": "Nastavitve, specifične za napravo, so bile počiscene", + "local_overrides_description": "Ta naprava ima nastavitve bralnika, ki se razlikujejo od vaših splošnih privzetih nastavitev:", + "clear_defaults": "Počisti vse privzete nastavitve", + "description": "Nastavite privzete nastavitve besedila za pogled bralnika. Te nastavitve se sinhronizirajo v vseh vaših napravah.", + "defaults_cleared": "Privzeti bralnik je bil počiščen", + "save_hint": "Shrani nastavitve samo za to napravo ali sinhroniziraj med vsemi napravami", + "save_as_default": "Shrani kot privzeto", + "save_to_device": "Ta naprava", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Neshranjene spremembe predogleda; nastavitve naprave se razlikujejo od globalnih", + "adjust_hint": "Prilagodite nastavitve zgoraj za predogled sprememb" + }, + "avatar": { + "upload": "Naloži avatar", + "change": "Spremeni avatar", + "remove_confirm_title": "Odstranim avatar?", + "updated": "Avatar posodobljen", + "removed": "Avatar odstranjen", + "description": "Naloži kvadratno sliko, ki jo boš uporabil kot svoj avatar.", + "remove_confirm_description": "S tem boš odstranil svojo trenutno sliko profila.", + "title": "Fotka profila", + "remove": "Odstrani avatar" } }, "ai": { @@ -59,7 +103,21 @@ "summarization_prompt": "Povzemni ukaz", "all_tagging": "Vse oznake", "tagging_rules": "Pravila za označevanje", - "tagging_rule_description": "Pozivi, ki jih dodaš tukaj, bodo vključeni kot pravila za model med ustvarjanjem oznak. Končne pozive si lahko ogledaš v razdelku za predogled pozivov." + "tagging_rule_description": "Pozivi, ki jih dodaš tukaj, bodo vključeni kot pravila za model med ustvarjanjem oznak. Končne pozive si lahko ogledaš v razdelku za predogled pozivov.", + "tag_style": "Slog oznake", + "auto_summarization_description": "Samodejno ustvari povzetke za tvoje zaznamke z uporabo UI.", + "auto_tagging": "Samodejno označevanje", + "titlecase_spaces": "Velike začetnice s presledki", + "lowercase_underscores": "Male črke s podčrtaji", + "inference_language": "Jezik sklepanja", + "titlecase_hyphens": "Velike začetnice s povezaji", + "lowercase_hyphens": "Male črke s povezaji", + "lowercase_spaces": "Male črke s presledki", + "inference_language_description": "Izberi jezik za oznake in povzetke, ustvarjene z umetno inteligenco.", + "tag_style_description": "Izberi obliko samodejno ustvarjenih oznak.", + "auto_tagging_description": "Samodejno ustvari oznake za tvoje zaznamke z uporabo UI.", + "camelCase": "camelCase", + "auto_summarization": "Samodejno povzemanje" }, "back_to_app": "Nazaj v aplikacijo", "webhooks": { @@ -470,7 +528,14 @@ "year_s_ago": " Let(a) nazaj", "history": "Nedavna iskanja", "title_contains": "Naslov vsebuje", - "title_does_not_contain": "Naslov ne vsebuje" + "title_does_not_contain": "Naslov ne vsebuje", + "is_broken_link": "Ima polomljeno povezavo", + "tags": "Oznake", + "no_suggestions": "Ni predlogov", + "filters": "Filtri", + "is_not_broken_link": "Ima delujočo povezavo", + "lists": "Seznami", + "feeds": "Viri" }, "tags": { "your_tags_info": "Oznake, ki si jih dodelil/a vsaj enkrat", @@ -536,7 +601,9 @@ "summary": "Povzetek", "quota": "Količina", "bookmarks": "Zaznamki", - "storage": "Shranjevanje" + "storage": "Shranjevanje", + "pdf": "Arhiviran PDF", + "default": "Privzeto" }, "actions": { "close_bulk_edit": "Zapri množično urejanje", @@ -581,7 +648,9 @@ "confirm": "Potrdi", "regenerate": "Osveži", "load_more": "Naloži več", - "edit_notes": "Uredi opombe" + "edit_notes": "Uredi opombe", + "preserve_as_pdf": "Shrani kot PDF", + "offline_copies": "Kopije brez povezave" }, "layouts": { "compact": "Kompaktno", @@ -745,7 +814,8 @@ "clipboard_copied": "Povezava je bila kopirana v odložišče!", "updated": "Zaznamek je bil posodobljen!", "refetch": "Ponovno pridobivanje je bilo dodano v čakalno vrsto!", - "full_page_archive": "Ustvarjanje arhiva celotne strani je bilo sproženo" + "full_page_archive": "Ustvarjanje arhiva celotne strani je bilo sproženo", + "preserve_pdf": "Ohranjanje PDF je bilo sproženo" }, "lists": { "created": "Seznam je bil ustvarjen!", @@ -778,7 +848,8 @@ "tabs": { "content": "Vsebina", "details": "Podrobnosti" - } + }, + "archive_info": "Arhivi se morda ne bodo pravilno izrisali v vrstici, če zahtevajo Javascript. Za najboljše rezultate <1>jih prenesi in odpri v brskalniku</1>." }, "highlights": { "no_highlights": "Še nimaš nobenih poudarkov." diff --git a/apps/web/lib/i18n/locales/sv/translation.json b/apps/web/lib/i18n/locales/sv/translation.json index f97949b7..b03c3d2e 100644 --- a/apps/web/lib/i18n/locales/sv/translation.json +++ b/apps/web/lib/i18n/locales/sv/translation.json @@ -39,7 +39,9 @@ "summary": "Sammanfattning", "quota": "Kvot", "bookmarks": "Bokmärken", - "storage": "Lagring" + "storage": "Lagring", + "pdf": "Arkiverad PDF", + "default": "Standard" }, "layouts": { "grid": "Rutnät", @@ -90,7 +92,9 @@ "confirm": "Bekräfta", "regenerate": "Återskapa", "load_more": "Ladda mer", - "edit_notes": "Redigera anteckningar" + "edit_notes": "Redigera anteckningar", + "preserve_as_pdf": "Spara som PDF", + "offline_copies": "Offlinelagrade kopior" }, "settings": { "back_to_app": "Tillbaka till app", @@ -115,6 +119,49 @@ "show": "Visa arkiverade bokmärken i taggar och listor", "hide": "Dölj arkiverade bokmärken i taggar och listor" } + }, + "reader_settings": { + "local_overrides_title": "Enhetsspecifika inställningar aktiva", + "using_default": "Använder klientstandard", + "clear_override_hint": "Rensa enhetsåsidosättning för att använda global inställning ({{value}})", + "font_size": "Teckenstorlek", + "font_family": "Typsnittsfamilj", + "preview_inline": "(förhandsvisning)", + "tooltip_preview": "Osparade förhandsvisningsändringar", + "save_to_all_devices": "Alla enheter", + "tooltip_local": "Enhetsinställningar skiljer sig från globala", + "reset_preview": "Återställ förhandsvisning", + "mono": "Monospace", + "line_height": "Radhöjd", + "tooltip_default": "Läsningsinställningar", + "title": "Läsarinställningar", + "serif": "Serif", + "preview": "Förhandsgranskning", + "not_set": "Ej inställt", + "clear_local_overrides": "Rensa enhetsinställningar", + "preview_text": "The quick brown fox jumps over the lazy dog. Så här kommer din läsarvytext att se ut.", + "local_overrides_cleared": "Enhetsspecifika inställningar har rensats", + "local_overrides_description": "Den här enheten har läsarinställningar som skiljer sig från dina globala standardinställningar:", + "clear_defaults": "Rensa alla standardvärden", + "description": "Konfigurera standardtextinställningar för läsarvyn. Dessa inställningar synkroniseras mellan alla dina enheter.", + "defaults_cleared": "Läsarstandardvärden har rensats", + "save_hint": "Spara inställningar endast för den här enheten eller synkronisera över alla enheter", + "save_as_default": "Spara som standard", + "save_to_device": "Den här enheten", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Osparade ändringar i förhandsvisningen; enhetsinställningarna skiljer sig från de globala", + "adjust_hint": "Justera inställningarna ovan för att förhandsvisa ändringarna" + }, + "avatar": { + "upload": "Ladda upp avatar", + "change": "Ändra avatar", + "remove_confirm_title": "Ta bort avatar?", + "updated": "Avatar uppdaterad", + "removed": "Avatar borttagen", + "description": "Ladda upp en kvadratisk bild för att använda som din avatar.", + "remove_confirm_description": "Detta kommer att ta bort ditt nuvarande profilfoto.", + "title": "Profilbild", + "remove": "Ta bort avatar" } }, "feeds": { @@ -134,7 +181,21 @@ "image_tagging": "Bildtaggning", "summarization": "Sammanfattning", "summarization_prompt": "Sammanfattningsprompt", - "all_tagging": "All taggning" + "all_tagging": "All taggning", + "tag_style": "Taggstil", + "auto_summarization_description": "Generera automatisk sammanfattning för dina bokmärken genom att använda AI.", + "auto_tagging": "Automatisk taggning", + "titlecase_spaces": "Versala inledande bokstäver med mellanslag", + "lowercase_underscores": "Små bokstäver med understreck", + "inference_language": "Språk för inferens", + "titlecase_hyphens": "Versala inledande bokstäver med bindestreck", + "lowercase_hyphens": "Små bokstäver med bindestreck", + "lowercase_spaces": "Små bokstäver med mellanslag", + "inference_language_description": "Välj språk för AI-genererade taggar och sammanfattningar.", + "tag_style_description": "Välj hur dina automatiskt genererade taggar ska formateras.", + "auto_tagging_description": "Generera automatiskt taggar för dina bokmärken genom att använda AI.", + "camelCase": "camelCase", + "auto_summarization": "Automatisk sammanfattning" }, "import": { "import_export": "Importera / exportera", @@ -144,6 +205,7 @@ "import_bookmarks_from_karakeep_export": "Importera bokmärken från Karakeep-export", "import_bookmarks_from_html_file": "Importera bokmärken från HTML-fil", "import_bookmarks_from_pocket_export": "Importera bokmärken från Pocket-export", + "import_bookmarks_from_matter_export": "Importera bokmärken från Matter-export", "export_links_and_notes": "Exportera länkar och anteckningar", "import_bookmarks_from_linkwarden_export": "Importera bokmärken från Linkwarden-export", "import_bookmarks_from_tab_session_manager_export": "Importera bokmärken från Tab Session Manager", @@ -705,7 +767,8 @@ "deleted": "Bokmärket har raderats!", "delete_from_list": "Bokmärket har raderats från listan", "clipboard_copied": "Länken har lags till i ditt urklipp!", - "refetch": "Hämtning har köats!" + "refetch": "Hämtning har köats!", + "preserve_pdf": "PDF-sparande har triggats" }, "lists": { "created": "Listan har skapats!", @@ -732,7 +795,8 @@ "tabs": { "content": "Innehåll", "details": "Detaljer" - } + }, + "archive_info": "Arkiv kanske inte återges korrekt inbäddade om de kräver Javascript. För bästa resultat, <1>ladda ner den och öppna den i din webbläsare</1>." }, "dialogs": { "bookmarks": { @@ -778,7 +842,14 @@ "year_s_ago": " År sedan", "history": "Senaste sökningar", "title_contains": "Titeln innehåller", - "title_does_not_contain": "Titeln innehåller inte" + "title_does_not_contain": "Titeln innehåller inte", + "is_broken_link": "Har trasig länk", + "tags": "Taggar", + "no_suggestions": "Inga förslag", + "filters": "Filter", + "is_not_broken_link": "Har fungerande länk", + "lists": "Listor", + "feeds": "Feeds" }, "highlights": { "no_highlights": "Du har inga markeringar ännu." diff --git a/apps/web/lib/i18n/locales/tr/translation.json b/apps/web/lib/i18n/locales/tr/translation.json index 97af51e0..8cd31dc0 100644 --- a/apps/web/lib/i18n/locales/tr/translation.json +++ b/apps/web/lib/i18n/locales/tr/translation.json @@ -39,7 +39,9 @@ "summary": "Özet", "quota": "Kota", "bookmarks": "Yer İmleri", - "storage": "Depolama" + "storage": "Depolama", + "pdf": "Arşivlenmiş PDF", + "default": "Varsayılan" }, "layouts": { "masonry": "Döşeme", @@ -90,7 +92,9 @@ "confirm": "Onayla", "regenerate": "Yeniden oluştur", "load_more": "Daha Fazla Yükle", - "edit_notes": "Notları Düzenle" + "edit_notes": "Notları Düzenle", + "preserve_as_pdf": "PDF olarak sakla", + "offline_copies": "Çevrimdışı Kopyalar" }, "highlights": { "no_highlights": "Henüz hiçbir öne çıkarılmış içeriğiniz yok." @@ -119,6 +123,49 @@ "show": "Arşivlenmiş yer imlerini etiketlerde ve listelerde göster", "hide": "Arşivlenmiş yer imlerini etiketlerde ve listelerde gizle" } + }, + "reader_settings": { + "local_overrides_title": "Cihaza özel ayarlar etkin", + "using_default": "İstemci varsayılanı kullanılıyor", + "clear_override_hint": "Genel ayarı ({{value}}) kullanmak için cihaz geçersiz kılmasını temizle", + "font_size": "Yazı Tipi Boyutu", + "font_family": "Yazı Tipi Ailesi", + "preview_inline": "(önizleme)", + "tooltip_preview": "Kaydedilmemiş önizleme değişiklikleri", + "save_to_all_devices": "Tüm cihazlar", + "tooltip_local": "Cihaz ayarları, genel ayarlardan farklı", + "reset_preview": "Önizlemeyi sıfırla", + "mono": "Tek Aralık", + "line_height": "Satır Yüksekliği", + "tooltip_default": "Okuma ayarları", + "title": "Okuyucu Ayarları", + "serif": "Serif", + "preview": "Önizleme", + "not_set": "Ayarlanmadı", + "clear_local_overrides": "Cihaz ayarlarını temizle", + "preview_text": "Hızlı kahverengi tilki tembel köpeğin üzerinden atlar. Okuyucu görünümü metniniz bu şekilde görünecek.", + "local_overrides_cleared": "Cihaza özel ayarlar temizlendi", + "local_overrides_description": "Bu cihaz, genel varsayılanlarınızdan farklı okuyucu ayarlarına sahiptir:", + "clear_defaults": "Tüm varsayılanları temizle", + "description": "Okuyucu görünümü için varsayılan metin ayarlarını yapılandır. Bu ayarlar tüm cihazlarınızda senkronize edilir.", + "defaults_cleared": "Okuyucu varsayılanları temizlendi", + "save_hint": "Ayarları yalnızca bu cihaz için kaydet veya tüm cihazlarda senkronize et", + "save_as_default": "Varsayılan olarak kaydet", + "save_to_device": "Bu cihaz", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Kaydedilmemiş önizleme değişiklikleri; cihaz ayarları genel ayarlardan farklı", + "adjust_hint": "Değişiklikleri önizlemek için yukarıdaki ayarları düzenle" + }, + "avatar": { + "upload": "Avatar yükle", + "change": "Avatarı değiştir", + "remove_confirm_title": "Avatarı kaldırılsın mı?", + "updated": "Avatar güncellendi", + "removed": "Avatar silindi", + "description": "Avatarınız olarak kullanmak için kare bir resim yükleyin.", + "remove_confirm_description": "Bu, mevcut profil fotoğrafınızı temizleyecek.", + "title": "Profil Fotoğrafı", + "remove": "Avatarı kaldır" } }, "ai": { @@ -132,7 +179,21 @@ "summarization_prompt": "Özetleme İstemi", "all_tagging": "Tüm Etiketleme", "text_tagging": "Metin Etiketleme", - "summarization": "Özetleme" + "summarization": "Özetleme", + "tag_style": "Etiket Stili", + "auto_summarization_description": "Yapay zeka kullanarak yer işaretlerin için otomatik olarak özet oluştur.", + "auto_tagging": "Otomatik etiketleme", + "titlecase_spaces": "Büyük harf ve boşluklu", + "lowercase_underscores": "Küçük harf ve alt çizgili", + "inference_language": "Çıkarım Dili", + "titlecase_hyphens": "Büyük harf ve tireli", + "lowercase_hyphens": "Küçük harf ve tireli", + "lowercase_spaces": "Küçük harf ve boşluklu", + "inference_language_description": "Yapay zeka tarafından oluşturulan etiketler ve özetler için dili seç.", + "tag_style_description": "Otomatik oluşturulan etiketlerinin nasıl biçimlendirileceğini seç.", + "auto_tagging_description": "Yapay zeka kullanarak yer işaretlerin için otomatik olarak etiket oluştur.", + "camelCase": "camelCase", + "auto_summarization": "Otomatik özetleme" }, "feeds": { "rss_subscriptions": "RSS Abonelikleri", @@ -145,6 +206,7 @@ "import_export_bookmarks": "Yer İşaretlerini İçe / Dışa Aktar", "import_bookmarks_from_html_file": "HTML Dosyasından Yer İşaretlerini İçe Aktar", "import_bookmarks_from_pocket_export": "Pocket Dışa Aktarımından Yer İşaretlerini İçe Aktar", + "import_bookmarks_from_matter_export": "Matter Dışa Aktarımından Yer İşaretlerini İçe Aktar", "import_bookmarks_from_omnivore_export": "Omnivore Dışa Aktarımından Yer İşaretlerini İçe Aktar", "import_bookmarks_from_karakeep_export": "Karakeep Dışa Aktarımından Yer İşaretlerini İçe Aktar", "export_links_and_notes": "Bağlantı ve Notları Dışa Aktar", @@ -649,7 +711,8 @@ "tabs": { "content": "İçerik", "details": "Ayrıntılar" - } + }, + "archive_info": "Arşivler Javascript gerektiriyorsa satır içi olarak doğru şekilde işlenmeyebilir. En iyi sonuçlar için, <1>indirin ve tarayıcınızda açın</1>." }, "editor": { "quickly_focus": "Bu alana hızlıca odaklanmak için ⌘ + E tuşlarına basabilirsiniz", @@ -717,7 +780,8 @@ "refetch": "Yeniden getir kuyruğa alındı!", "full_page_archive": "Tüm Sayfa Arşivi oluşturma başlatıldı", "delete_from_list": "Yer işareti listeden silindi", - "clipboard_copied": "Bağlantı panonuza eklendi!" + "clipboard_copied": "Bağlantı panonuza eklendi!", + "preserve_pdf": "PDF olarak saklama tetiklendi" }, "lists": { "created": "Liste oluşturuldu!", @@ -775,7 +839,14 @@ "month_s_ago": " Ay Önce", "history": "Son Aramalar", "title_contains": "Başlık İçeriyor", - "title_does_not_contain": "Başlık İçermiyor" + "title_does_not_contain": "Başlık İçermiyor", + "is_broken_link": "Bozuk Bağlantısı Var", + "tags": "Etiketler", + "no_suggestions": "Öneri yok", + "filters": "Filtreler", + "is_not_broken_link": "Çalışan Bağlantısı Var", + "lists": "Listeler", + "feeds": "Akışlar" }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/uk/translation.json b/apps/web/lib/i18n/locales/uk/translation.json index 819584ef..1329db9c 100644 --- a/apps/web/lib/i18n/locales/uk/translation.json +++ b/apps/web/lib/i18n/locales/uk/translation.json @@ -39,7 +39,9 @@ "summary": "Короткий зміст", "quota": "Квота", "bookmarks": "Закладки", - "storage": "Сховище" + "storage": "Сховище", + "pdf": "Архівні PDF", + "default": "За замовчуванням" }, "actions": { "sign_out": "Вийти", @@ -84,7 +86,9 @@ "confirm": "Підтвердити", "regenerate": "Відновити", "load_more": "Завантажити більше", - "edit_notes": "Редагувати примітки" + "edit_notes": "Редагувати примітки", + "preserve_as_pdf": "Зберегти як PDF", + "offline_copies": "Офлайн копії" }, "settings": { "webhooks": { @@ -128,6 +132,49 @@ "show": "Показувати заархівовані закладки в тегах і списках", "hide": "Приховувати заархівовані закладки в тегах і списках" } + }, + "reader_settings": { + "local_overrides_title": "Активні налаштування для конкретного пристрою", + "using_default": "Використовується типове значення клієнта", + "clear_override_hint": "Очистити переналаштування пристрою, щоб використовувати глобальні налаштування ({{value}})", + "font_size": "Розмір шрифту", + "font_family": "Сімейство шрифтів", + "preview_inline": "(попередній перегляд)", + "tooltip_preview": "Не збережені зміни попереднього перегляду", + "save_to_all_devices": "Усі пристрої", + "tooltip_local": "Налаштування пристрою відрізняються від глобальних", + "reset_preview": "Скинути попередній перегляд", + "mono": "Моноширинний", + "line_height": "міжрядковий інтервал", + "tooltip_default": "Налаштування читання", + "title": "Параметри читання", + "serif": "Serif", + "preview": "Перегляд", + "not_set": "Не встановлено", + "clear_local_overrides": "Очистити налаштування пристрою", + "preview_text": "Швидкий бурий лис стрибає через ледачого пса. Ось як виглядатиме ваш текст у режимі читання.", + "local_overrides_cleared": "Налаштування для конкретного пристрою очищено", + "local_overrides_description": "На цьому пристрої параметри читання відрізняються від ваших глобальних типових значень:", + "clear_defaults": "Очистити всі типові налаштування", + "description": "Налаштуйте параметри тексту для перегляду в режимі читання. Ці параметри синхронізуються на всіх ваших пристроях.", + "defaults_cleared": "Типові значення читання очищено", + "save_hint": "Зберегти налаштування тільки для цього пристрою або синхронізувати на всіх пристроях", + "save_as_default": "Зберегти як типові", + "save_to_device": "Цей пристрій", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Не збережені зміни попереднього перегляду; налаштування пристрою відрізняються від глобальних", + "adjust_hint": "Налаштуйте параметри вище, щоб попередньо переглянути зміни" + }, + "avatar": { + "upload": "Завантажити аватар", + "change": "Змінити аватар", + "remove_confirm_title": "Видалити аватар?", + "updated": "Аватар оновлено", + "removed": "Аватар видалено", + "description": "Завантаж квадратне зображення, щоб використовувати його як свій аватар.", + "remove_confirm_description": "Це видалить поточне фото профілю.", + "title": "Фото профілю", + "remove": "Видалити аватар" } }, "ai": { @@ -141,7 +188,21 @@ "image_tagging": "Тегування зображень", "summarization": "Підсумовування", "prompt_preview": "Попередній перегляд підказки", - "tagging_rules": "Правила тегів" + "tagging_rules": "Правила тегів", + "tag_style": "Стиль тегів", + "auto_summarization_description": "Автоматично створюйте підсумки для закладок, використовуючи штучний інтелект.", + "auto_tagging": "Автоматичне тегування", + "titlecase_spaces": "З великої літери з пробілами", + "lowercase_underscores": "З маленької літери з підкресленнями", + "inference_language": "Мова висновування", + "titlecase_hyphens": "З великої літери з дефісами", + "lowercase_hyphens": "З маленької літери з дефісами", + "lowercase_spaces": "З маленької літери з пробілами", + "inference_language_description": "Вибери мову для тегів і підсумків, згенерованих ШІ.", + "tag_style_description": "Обери, як форматуватимуться твої автоматично створені теги.", + "auto_tagging_description": "Автоматично генеруйте теги для своїх закладок за допомогою штучного інтелекту.", + "camelCase": "camelCase", + "auto_summarization": "Автоматичне підсумовування" }, "feeds": { "rss_subscriptions": "RSS-підписки", @@ -154,6 +215,7 @@ "import_export_bookmarks": "Імпорт / Експорт закладок", "import_bookmarks_from_html_file": "Імпортувати закладки з HTML-файлу", "import_bookmarks_from_pocket_export": "Імпортувати закладки з експорту Pocket", + "import_bookmarks_from_matter_export": "Імпортувати закладки з експорту Matter", "import_bookmarks_from_omnivore_export": "Імпорт закладок з експорту Omnivore", "import_bookmarks_from_linkwarden_export": "Імпортувати закладки з експорту Linkwarden", "import_bookmarks_from_karakeep_export": "Імпортувати закладки з експорту Karakeep", @@ -427,7 +489,14 @@ "year_s_ago": " Років тому", "history": "Нещодавні пошуки", "title_contains": "Назва містить", - "title_does_not_contain": "Назва не містить" + "title_does_not_contain": "Назва не містить", + "is_broken_link": "Має недійсне посилання", + "tags": "Теги", + "no_suggestions": "Немає пропозицій", + "filters": "Фільтри", + "is_not_broken_link": "Має дійсне посилання", + "lists": "Списки", + "feeds": "Стрічки новин" }, "preview": { "cached_content": "Кешований вміст", @@ -436,7 +505,8 @@ "tabs": { "details": "Деталі", "content": "Вміст" - } + }, + "archive_info": "Архіви можуть неправильно відображатися вбудовано, якщо їм потрібен Javascript. Для кращого результату, <1>завантажте їх і відкрийте у своєму браузері</1>." }, "layouts": { "masonry": "Кам'яна кладка", @@ -763,7 +833,8 @@ "delete_from_list": "Закладку видалено зі списку", "clipboard_copied": "Посилання додано до вашого буфера обміну!", "updated": "Закладку оновлено!", - "deleted": "Закладку видалено!" + "deleted": "Закладку видалено!", + "preserve_pdf": "Збереження PDF ініційовано" }, "lists": { "created": "Список створено!", diff --git a/apps/web/lib/i18n/locales/vi/translation.json b/apps/web/lib/i18n/locales/vi/translation.json index 920f3435..06993802 100644 --- a/apps/web/lib/i18n/locales/vi/translation.json +++ b/apps/web/lib/i18n/locales/vi/translation.json @@ -42,7 +42,9 @@ "confirm": "Xác nhận", "regenerate": "Tạo lại", "load_more": "Tải thêm", - "edit_notes": "Sửa ghi chú" + "edit_notes": "Sửa ghi chú", + "preserve_as_pdf": "Lưu giữ dưới dạng PDF", + "offline_copies": "Bản sao ngoại tuyến" }, "layouts": { "list": "Danh sách", @@ -65,6 +67,7 @@ "import_export_bookmarks": "Nhập / Xuất đánh dấu trang", "import_bookmarks_from_linkwarden_export": "Nhập dấu trang từ bản xuất Linkwarden", "import_bookmarks_from_pocket_export": "Nhập dấu trang từ bản xuất Pocket", + "import_bookmarks_from_matter_export": "Nhập dấu trang từ bản xuất Matter", "import_bookmarks_from_omnivore_export": "Nhập dấu trang từ xuất Omnivore", "import_bookmarks_from_karakeep_export": "Nhập dấu trang từ bản xuất Karakeep", "import_bookmarks_from_tab_session_manager_export": "Nhập dấu trang từ Tab Session Manager", @@ -111,7 +114,21 @@ "summarization": "Tóm tắt", "all_tagging": "Tất cả nhãn", "image_tagging": "Nhãn cho hình ảnh", - "text_tagging": "Nhãn cho văn bản" + "text_tagging": "Nhãn cho văn bản", + "tag_style": "Kiểu Thẻ", + "auto_summarization_description": "Tự động tạo bản tóm tắt cho dấu trang bằng AI.", + "auto_tagging": "Tự động gắn thẻ", + "titlecase_spaces": "Tiêu đề viết hoa có dấu cách", + "lowercase_underscores": "Chữ thường có dấu gạch dưới", + "inference_language": "Ngôn ngữ Suy luận", + "titlecase_hyphens": "Tiêu đề viết hoa có dấu gạch ngang", + "lowercase_hyphens": "Chữ thường có dấu gạch ngang", + "lowercase_spaces": "Chữ thường có dấu cách", + "inference_language_description": "Chọn ngôn ngữ cho các thẻ và tóm tắt do AI tạo.", + "tag_style_description": "Chọn cách định dạng các thẻ tự động tạo của bạn.", + "auto_tagging_description": "Tự động tạo thẻ cho dấu trang bằng AI.", + "camelCase": "camelCase", + "auto_summarization": "Tự động tóm tắt" }, "info": { "basic_details": "Thông tin cơ bản", @@ -134,6 +151,49 @@ "show": "Hiển thị các bookmark đã lưu trữ trong tag và danh sách", "hide": "Ẩn các bookmark đã lưu trữ trong tag và danh sách" } + }, + "reader_settings": { + "local_overrides_title": "Đã kích hoạt cài đặt dành riêng cho thiết bị", + "using_default": "Sử dụng mặc định của ứng dụng", + "clear_override_hint": "Xóa ghi đè thiết bị để sử dụng cài đặt chung ({{value}})", + "font_size": "Cỡ chữ", + "font_family": "Họ phông chữ", + "preview_inline": "(xem trước)", + "tooltip_preview": "Các thay đổi xem trước chưa được lưu", + "save_to_all_devices": "Tất cả các thiết bị", + "tooltip_local": "Cài đặt thiết bị khác với cài đặt chung", + "reset_preview": "Đặt lại bản xem trước", + "mono": "Đơn cách", + "line_height": "Chiều cao dòng", + "tooltip_default": "Cài đặt đọc", + "title": "Cài đặt Trình đọc", + "serif": "Chân phương", + "preview": "Xem trước", + "not_set": "Chưa đặt", + "clear_local_overrides": "Xóa cài đặt thiết bị", + "preview_text": "Một con cáo nâu nhanh chóng nhảy qua con chó lười biếng. Đây là cách mà văn bản chế độ xem trình đọc của bạn sẽ hiển thị.", + "local_overrides_cleared": "Đã xóa cài đặt cho thiết bị", + "local_overrides_description": "Thiết bị này có các cài đặt trình đọc khác với cài đặt mặc định toàn cầu của bạn:", + "clear_defaults": "Xóa tất cả mặc định", + "description": "Cấu hình cài đặt văn bản mặc định cho chế độ xem trình đọc. Các cài đặt này đồng bộ hóa trên tất cả các thiết bị của bạn.", + "defaults_cleared": "Đã xóa các mặc định của trình đọc", + "save_hint": "Lưu cài đặt chỉ cho thiết bị này hoặc đồng bộ hóa trên tất cả các thiết bị", + "save_as_default": "Lưu làm mặc định", + "save_to_device": "Thiết bị này", + "sans": "Không chân phương", + "tooltip_preview_and_local": "Các thay đổi xem trước chưa được lưu; cài đặt thiết bị khác với cài đặt chung", + "adjust_hint": "Điều chỉnh các cài đặt ở trên để xem trước các thay đổi" + }, + "avatar": { + "upload": "Tải lên ảnh đại diện", + "change": "Đổi ảnh đại diện", + "remove_confirm_title": "Xóa ảnh đại diện?", + "updated": "Đã cập nhật ảnh đại diện", + "removed": "Đã xóa ảnh đại diện", + "description": "Tải lên ảnh vuông để dùng làm ảnh đại diện nha.", + "remove_confirm_description": "Hành động này sẽ xóa ảnh hồ sơ hiện tại của bạn đó.", + "title": "Ảnh hồ sơ", + "remove": "Xóa ảnh đại diện" } }, "user_settings": "Cài đặt người dùng", @@ -523,7 +583,9 @@ "summary": "Tóm tắt", "quota": "Hạn ngạch", "bookmarks": "Dấu trang", - "storage": "Lưu trữ" + "storage": "Lưu trữ", + "pdf": "PDF đã lưu trữ", + "default": "Mặc định" }, "highlights": { "no_highlights": "Bạn chưa có đánh dấu nào." @@ -652,7 +714,14 @@ "year_s_ago": " Năm trước", "history": "Tìm kiếm gần đây", "title_contains": "Chứa trong tiêu đề", - "title_does_not_contain": "Không chứa trong tiêu đề" + "title_does_not_contain": "Không chứa trong tiêu đề", + "is_broken_link": "Có liên kết hỏng", + "tags": "Thẻ", + "no_suggestions": "Không có đề xuất nào", + "filters": "Bộ lọc", + "is_not_broken_link": "Có liên kết hoạt động", + "lists": "Danh sách", + "feeds": "Nguồn cấp dữ liệu" }, "tags": { "all_tags": "Tất cả nhãn", @@ -761,7 +830,8 @@ "refetch": "Đã xếp hàng tìm nạp lại!", "full_page_archive": "Đã kích hoạt tạo bản lưu trữ toàn trang", "delete_from_list": "Đã xóa dấu trang khỏi danh sách", - "clipboard_copied": "Liên kết đã được thêm vào bảng nhớ tạm của bạn!" + "clipboard_copied": "Liên kết đã được thêm vào bảng nhớ tạm của bạn!", + "preserve_pdf": "Đã kích hoạt lưu giữ PDF" }, "lists": { "created": "Đã tạo danh sách!", @@ -781,7 +851,8 @@ "tabs": { "content": "Nội dung", "details": "Chi tiết" - } + }, + "archive_info": "Các bản lưu trữ có thể không hiển thị chính xác nội dòng nếu chúng yêu cầu Javascript. Để có kết quả tốt nhất, <1>hãy tải xuống và mở trong trình duyệt của bạn</1>." }, "bookmark_editor": { "title": "Sửa dấu trang", diff --git a/apps/web/lib/i18n/locales/zh/translation.json b/apps/web/lib/i18n/locales/zh/translation.json index 771f47f8..7f16a5f6 100644 --- a/apps/web/lib/i18n/locales/zh/translation.json +++ b/apps/web/lib/i18n/locales/zh/translation.json @@ -39,7 +39,9 @@ "summary": "摘要", "quota": "配额", "bookmarks": "书签", - "storage": "存储" + "storage": "存储", + "pdf": "已存档的 PDF", + "default": "默认" }, "layouts": { "masonry": "砌体", @@ -90,7 +92,9 @@ "confirm": "确认", "regenerate": "重新生成", "load_more": "加载更多", - "edit_notes": "编辑备注" + "edit_notes": "编辑备注", + "preserve_as_pdf": "另存为 PDF", + "offline_copies": "离线副本" }, "settings": { "back_to_app": "返回应用", @@ -116,6 +120,49 @@ "show": "在标签和列表中显示已存档的书签", "hide": "在标签和列表中隐藏已存档的书签" } + }, + "reader_settings": { + "local_overrides_title": "设备特定的设置已激活", + "using_default": "正在使用客户端默认值", + "clear_override_hint": "清除设备覆盖以使用全局设置({{value}})", + "font_size": "字体大小", + "font_family": "字体系列", + "preview_inline": "(预览)", + "tooltip_preview": "未保存的预览更改", + "save_to_all_devices": "所有设备", + "tooltip_local": "设备设置与全局设置不同", + "reset_preview": "重置预览", + "mono": "等宽", + "line_height": "行高", + "tooltip_default": "阅读设置", + "title": "阅读器设置", + "serif": "衬线", + "preview": "预览", + "not_set": "未设置", + "clear_local_overrides": "清除设备设置", + "preview_text": "敏捷的棕色狐狸跳过懒惰的狗。这是您的阅读器视图文本的显示方式。", + "local_overrides_cleared": "设备特定的设置已清除", + "local_overrides_description": "此设备上的阅读器设置与您的全局默认值不同:", + "clear_defaults": "清除所有默认值", + "description": "配置阅读器视图的默认文本设置。这些设置将在您的所有设备上同步。", + "defaults_cleared": "阅读器默认值已清除", + "save_hint": "仅保存此设备的设置,还是在所有设备同步", + "save_as_default": "保存为默认值", + "save_to_device": "此设备", + "sans": "无衬线", + "tooltip_preview_and_local": "未保存的预览更改;设备设置与全局设置不同", + "adjust_hint": "调整以上设置以预览更改" + }, + "avatar": { + "upload": "上传虚拟形象", + "change": "更改虚拟形象", + "remove_confirm_title": "移除虚拟形象?", + "updated": "虚拟形象已更新", + "removed": "虚拟形象已移除", + "description": "上传一张方形图片作为您的虚拟形象。", + "remove_confirm_description": "这会清除您当前的头像照片。", + "title": "头像照片", + "remove": "移除虚拟形象" } }, "ai": { @@ -129,7 +176,21 @@ "image_tagging": "图片标记", "text_tagging": "文字标记", "all_tagging": "所有标记", - "summarization_prompt": "摘要生成提示" + "summarization_prompt": "摘要生成提示", + "tag_style": "标签样式", + "auto_summarization_description": "使用 AI 自动为你的书签生成摘要。", + "auto_tagging": "自动添加标签", + "titlecase_spaces": "带空格的首字母大写", + "lowercase_underscores": "带下划线的小写", + "inference_language": "推理语言", + "titlecase_hyphens": "带连字符的首字母大写", + "lowercase_hyphens": "带连字符的小写", + "lowercase_spaces": "带空格的小写", + "inference_language_description": "为 AI 生成的标签和摘要选择语言。", + "tag_style_description": "选择自动生成的标签应如何格式化。", + "auto_tagging_description": "使用 AI 自动为你的书签生成标签。", + "camelCase": "驼峰式命名", + "auto_summarization": "自动摘要" }, "feeds": { "rss_subscriptions": "RSS订阅", @@ -142,6 +203,7 @@ "import_export_bookmarks": "导入/导出书签", "import_bookmarks_from_html_file": "从HTML文件导入书签", "import_bookmarks_from_pocket_export": "从Pocket导出导入书签", + "import_bookmarks_from_matter_export": "从Matter导出导入书签", "import_bookmarks_from_omnivore_export": "从Omnivore导出导入书签", "import_bookmarks_from_karakeep_export": "从Karakeep导出导入书签", "export_links_and_notes": "导出链接和笔记", @@ -646,7 +708,8 @@ "tabs": { "content": "内容", "details": "详情" - } + }, + "archive_info": "如果存档需要 Javascript,则可能无法正确地以内联方式呈现。为了获得最佳效果,<1>请下载并在浏览器中打开它</1>。" }, "editor": { "quickly_focus": "您可以按⌘ + E快速聚焦到此字段", @@ -714,7 +777,8 @@ "refetch": "重新获取已排队!", "full_page_archive": "已触发完整页面归档创建", "delete_from_list": "书签已从列表中删除", - "clipboard_copied": "链接已添加到您的剪贴板!" + "clipboard_copied": "链接已添加到您的剪贴板!", + "preserve_pdf": "已触发 PDF 保存" }, "lists": { "created": "列表已创建!", @@ -772,7 +836,14 @@ "week_s_ago": " {weeks} 周前", "history": "最近搜索", "title_contains": "标题包含", - "title_does_not_contain": "标题不包含" + "title_does_not_contain": "标题不包含", + "is_broken_link": "有损坏的链接", + "tags": "标签", + "no_suggestions": "没有建议", + "filters": "筛选器", + "is_not_broken_link": "有可用的链接", + "lists": "列表", + "feeds": "订阅" }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/zhtw/translation.json b/apps/web/lib/i18n/locales/zhtw/translation.json index 92c4f41b..cafa02d6 100644 --- a/apps/web/lib/i18n/locales/zhtw/translation.json +++ b/apps/web/lib/i18n/locales/zhtw/translation.json @@ -39,7 +39,9 @@ "summary": "摘要", "quota": "配額", "bookmarks": "書籤", - "storage": "儲存空間" + "storage": "儲存空間", + "pdf": "已封存的 PDF", + "default": "預設" }, "layouts": { "masonry": "瀑布式", @@ -90,7 +92,9 @@ "confirm": "確認", "regenerate": "重新產生", "load_more": "載入更多", - "edit_notes": "編輯註解" + "edit_notes": "編輯註解", + "preserve_as_pdf": "儲存為 PDF", + "offline_copies": "離線副本" }, "settings": { "back_to_app": "返回應用程式", @@ -116,6 +120,49 @@ "open_external_url": "開啟原始網址", "open_bookmark_details": "開啟書籤詳細資訊" } + }, + "reader_settings": { + "local_overrides_title": "裝置專用設定已啟動", + "using_default": "使用用戶端預設值", + "clear_override_hint": "清除裝置覆寫以使用全域設定({{value}})", + "font_size": "字型大小", + "font_family": "字型", + "preview_inline": "(預覽)", + "tooltip_preview": "未儲存的預覽變更", + "save_to_all_devices": "所有裝置", + "tooltip_local": "裝置設定與全域不同", + "reset_preview": "重設預覽", + "mono": "等寬字體", + "line_height": "行高", + "tooltip_default": "閱讀設定", + "title": "閱讀器設定", + "serif": "襯線體", + "preview": "預覽", + "not_set": "未設定", + "clear_local_overrides": "清除裝置設定", + "preview_text": "敏捷的棕色狐狸跳過懶惰的狗。您的閱讀器檢視文字會像這樣顯示。", + "local_overrides_cleared": "裝置專用設定已清除", + "local_overrides_description": "此裝置具有與全域預設值不同的閱讀器設定:", + "clear_defaults": "清除所有預設值", + "description": "設定閱讀器檢視的預設文字設定。這些設定會在您的所有裝置之間同步。", + "defaults_cleared": "閱讀器預設值已清除", + "save_hint": "僅儲存此裝置的設定,或跨所有裝置同步", + "save_as_default": "儲存為預設值", + "save_to_device": "此裝置", + "sans": "無襯線體", + "tooltip_preview_and_local": "未儲存的預覽變更數目;裝置設定與全域不同", + "adjust_hint": "調整以上設定以預覽變更" + }, + "avatar": { + "upload": "上傳頭像", + "change": "變更頭像", + "remove_confirm_title": "要移除頭像嗎?", + "updated": "頭像已更新", + "removed": "頭像已移除", + "description": "上傳一張正方形圖片做為您的頭像。", + "remove_confirm_description": "這會清除您目前的個人資料相片。", + "title": "個人資料相片", + "remove": "移除頭像" } }, "ai": { @@ -129,7 +176,21 @@ "text_tagging": "文字標籤", "image_tagging": "圖片標籤", "summarization": "摘要", - "summarization_prompt": "摘要提示詞" + "summarization_prompt": "摘要提示詞", + "tag_style": "標籤樣式", + "auto_summarization_description": "使用 AI 自動為你的書籤產生摘要。", + "auto_tagging": "自動標記", + "titlecase_spaces": "首字大寫,含空格", + "lowercase_underscores": "小寫,含底線", + "inference_language": "推論語言", + "titlecase_hyphens": "首字大寫,含連字號", + "lowercase_hyphens": "小寫,含連字號", + "lowercase_spaces": "小寫,含空格", + "inference_language_description": "選擇 AI 產生的標籤和摘要的語言。", + "tag_style_description": "選擇自動產生的標籤應如何格式化。", + "auto_tagging_description": "使用 AI 自動為你的書籤產生標籤。", + "camelCase": "駝峰式大小寫", + "auto_summarization": "自動摘要" }, "feeds": { "rss_subscriptions": "RSS 訂閱", @@ -142,6 +203,7 @@ "import_export_bookmarks": "匯入/匯出書籤", "import_bookmarks_from_html_file": "從 HTML 檔案匯入書籤", "import_bookmarks_from_pocket_export": "從 Pocket 匯出檔案匯入書籤", + "import_bookmarks_from_matter_export": "從 Matter 匯出檔案匯入書籤", "import_bookmarks_from_omnivore_export": "從 Omnivore 匯出檔案匯入書籤", "import_bookmarks_from_karakeep_export": "從 Karakeep 匯出檔案匯入書籤", "export_links_and_notes": "匯出連結和筆記", @@ -646,7 +708,8 @@ "tabs": { "content": "內容", "details": "詳細資訊" - } + }, + "archive_info": "如果封存檔需要 Javascript,可能無法正確地內嵌呈現。為了獲得最佳效果,<1>請下載並在瀏覽器中開啟</1>。" }, "editor": { "quickly_focus": "您可以按下 ⌘ + E 快速聚焦此欄位", @@ -714,7 +777,8 @@ "refetch": "已將重新抓取加入佇列!", "full_page_archive": "已觸發完整網頁封存建立", "delete_from_list": "已從清單中移除書籤", - "clipboard_copied": "連結已複製到剪貼簿!" + "clipboard_copied": "連結已複製到剪貼簿!", + "preserve_pdf": "已觸發 PDF 儲存" }, "lists": { "created": "清單已建立!", @@ -772,7 +836,14 @@ "year_s_ago": " 幾年前", "history": "近期搜尋", "title_contains": "標題包含", - "title_does_not_contain": "標題不包含" + "title_does_not_contain": "標題不包含", + "is_broken_link": "連結已損毀", + "tags": "標籤", + "no_suggestions": "沒有任何建議", + "filters": "篩選器", + "is_not_broken_link": "擁有可用的連結", + "lists": "清單", + "feeds": "動態饋給" }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/providers.tsx b/apps/web/lib/providers.tsx index a3debdb9..8e247f6f 100644 --- a/apps/web/lib/providers.tsx +++ b/apps/web/lib/providers.tsx @@ -1,21 +1,21 @@ "use client"; import type { UserLocalSettings } from "@/lib/userLocalSettings/types"; -import type { Session } from "next-auth"; import React, { useState } from "react"; import { ThemeProvider } from "@/components/theme-provider"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { Session, SessionProvider } from "@/lib/auth/client"; import { UserLocalSettingsCtx } from "@/lib/userLocalSettings/bookmarksLayout"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { httpBatchLink, loggerLink } from "@trpc/client"; -import { SessionProvider } from "next-auth/react"; +import { createTRPCClient, httpBatchLink, loggerLink } from "@trpc/client"; import superjson from "superjson"; import type { ClientConfig } from "@karakeep/shared/config"; +import type { AppRouter } from "@karakeep/trpc/routers/_app"; +import { TRPCProvider } from "@karakeep/shared-react/trpc"; import { ClientConfigCtx } from "./clientConfig"; import CustomI18nextProvider from "./i18n/provider"; -import { api } from "./trpc"; function makeQueryClient() { return new QueryClient({ @@ -59,7 +59,7 @@ export default function Providers({ const queryClient = getQueryClient(); const [trpcClient] = useState(() => - api.createClient({ + createTRPCClient<AppRouter>({ links: [ loggerLink({ enabled: (op) => @@ -80,8 +80,8 @@ export default function Providers({ <ClientConfigCtx.Provider value={clientConfig}> <UserLocalSettingsCtx.Provider value={userLocalSettings}> <SessionProvider session={session}> - <api.Provider client={trpcClient} queryClient={queryClient}> - <QueryClientProvider client={queryClient}> + <QueryClientProvider client={queryClient}> + <TRPCProvider trpcClient={trpcClient} queryClient={queryClient}> <CustomI18nextProvider lang={userLocalSettings.lang}> <ThemeProvider attribute="class" @@ -94,8 +94,8 @@ export default function Providers({ </TooltipProvider> </ThemeProvider> </CustomI18nextProvider> - </QueryClientProvider> - </api.Provider> + </TRPCProvider> + </QueryClientProvider> </SessionProvider> </UserLocalSettingsCtx.Provider> </ClientConfigCtx.Provider> diff --git a/apps/web/lib/readerSettings.tsx b/apps/web/lib/readerSettings.tsx new file mode 100644 index 00000000..5966f287 --- /dev/null +++ b/apps/web/lib/readerSettings.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from "react"; + +import { + ReaderSettingsProvider as BaseReaderSettingsProvider, + useReaderSettingsContext, +} from "@karakeep/shared-react/hooks/reader-settings"; +import { + ReaderSettings, + ReaderSettingsPartial, +} from "@karakeep/shared/types/readers"; + +const LOCAL_STORAGE_KEY = "karakeep-reader-settings"; + +function getLocalOverridesFromStorage(): ReaderSettingsPartial { + if (typeof window === "undefined") return {}; + try { + const stored = localStorage.getItem(LOCAL_STORAGE_KEY); + return stored ? JSON.parse(stored) : {}; + } catch { + return {}; + } +} + +function saveLocalOverridesToStorage(overrides: ReaderSettingsPartial): void { + if (typeof window === "undefined") return; + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(overrides)); +} + +// Session overrides context - web-specific feature for live preview +interface SessionOverridesContextValue { + sessionOverrides: ReaderSettingsPartial; + setSessionOverrides: React.Dispatch< + React.SetStateAction<ReaderSettingsPartial> + >; +} + +const SessionOverridesContext = + createContext<SessionOverridesContextValue | null>(null); + +export function ReaderSettingsProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [sessionOverrides, setSessionOverrides] = + useState<ReaderSettingsPartial>({}); + + const sessionValue = useMemo( + () => ({ + sessionOverrides, + setSessionOverrides, + }), + [sessionOverrides], + ); + + // Memoize callbacks to prevent unnecessary re-renders + const getLocalOverrides = useCallback(getLocalOverridesFromStorage, []); + const saveLocalOverrides = useCallback(saveLocalOverridesToStorage, []); + const onClearSessionOverrides = useCallback(() => { + setSessionOverrides({}); + }, []); + + return ( + <BaseReaderSettingsProvider + getLocalOverrides={getLocalOverrides} + saveLocalOverrides={saveLocalOverrides} + sessionOverrides={sessionOverrides} + onClearSessionOverrides={onClearSessionOverrides} + > + <SessionOverridesContext.Provider value={sessionValue}> + {children} + </SessionOverridesContext.Provider> + </BaseReaderSettingsProvider> + ); +} + +export function useReaderSettings() { + const sessionContext = useContext(SessionOverridesContext); + if (!sessionContext) { + throw new Error( + "useReaderSettings must be used within a ReaderSettingsProvider", + ); + } + + const { sessionOverrides, setSessionOverrides } = sessionContext; + const baseSettings = useReaderSettingsContext(); + + // Update session override (live preview, not persisted) + const updateSession = useCallback( + (updates: ReaderSettingsPartial) => { + setSessionOverrides((prev) => ({ ...prev, ...updates })); + }, + [setSessionOverrides], + ); + + // Clear all session overrides + const clearSession = useCallback(() => { + setSessionOverrides({}); + }, [setSessionOverrides]); + + // Save current settings to local storage (this device only) + const saveToDevice = useCallback(() => { + const newLocalOverrides = { + ...baseSettings.localOverrides, + ...sessionOverrides, + }; + baseSettings.setLocalOverrides(newLocalOverrides); + saveLocalOverridesToStorage(newLocalOverrides); + setSessionOverrides({}); + }, [baseSettings, sessionOverrides, setSessionOverrides]); + + // Clear a single local override + const clearLocalOverride = useCallback( + (key: keyof ReaderSettings) => { + baseSettings.clearLocal(key); + }, + [baseSettings], + ); + + // Check if there are unsaved session changes + const hasSessionChanges = Object.keys(sessionOverrides).length > 0; + + return { + // Current effective settings (what should be displayed) + settings: baseSettings.settings, + + // Raw values for UI indicators + serverSettings: baseSettings.serverDefaults, + localOverrides: baseSettings.localOverrides, + sessionOverrides, + + // State indicators + hasSessionChanges, + hasLocalOverrides: baseSettings.hasLocalOverrides, + isSaving: baseSettings.isSaving, + + // Actions + updateSession, + clearSession, + saveToDevice, + clearLocalOverrides: baseSettings.clearAllLocal, + clearLocalOverride, + saveToServer: baseSettings.saveAsDefault, + updateServerSetting: baseSettings.saveAsDefault, + clearServerDefaults: baseSettings.clearAllDefaults, + }; +} diff --git a/apps/web/lib/trpc.tsx b/apps/web/lib/trpc.tsx deleted file mode 100644 index 1478684f..00000000 --- a/apps/web/lib/trpc.tsx +++ /dev/null @@ -1,7 +0,0 @@ -"use client"; - -import { createTRPCReact } from "@trpc/react-query"; - -import type { AppRouter } from "@karakeep/trpc/routers/_app"; - -export const api = createTRPCReact<AppRouter>(); diff --git a/apps/web/lib/userSettings.tsx b/apps/web/lib/userSettings.tsx index c7a133b7..105e258e 100644 --- a/apps/web/lib/userSettings.tsx +++ b/apps/web/lib/userSettings.tsx @@ -1,11 +1,11 @@ "use client"; import { createContext, useContext } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { ZUserSettings } from "@karakeep/shared/types/users"; -import { api } from "./trpc"; - export const UserSettingsContext = createContext<ZUserSettings>({ bookmarkClickAction: "open_original_link", archiveDisplayBehaviour: "show", @@ -13,6 +13,14 @@ export const UserSettingsContext = createContext<ZUserSettings>({ backupsEnabled: false, backupsFrequency: "daily", backupsRetentionDays: 7, + readerFontSize: null, + readerLineHeight: null, + readerFontFamily: null, + autoTaggingEnabled: null, + autoSummarizationEnabled: null, + tagStyle: "as-generated", + curatedTagIds: null, + inferredTagLang: null, }); export function UserSettingsContextProvider({ @@ -22,9 +30,12 @@ export function UserSettingsContextProvider({ userSettings: ZUserSettings; children: React.ReactNode; }) { - const { data } = api.users.settings.useQuery(undefined, { - initialData: userSettings, - }); + const api = useTRPC(); + const { data } = useQuery( + api.users.settings.queryOptions(undefined, { + initialData: userSettings, + }), + ); return ( <UserSettingsContext.Provider value={data}> diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 5f1c2bf6..136f6a22 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -1,5 +1,10 @@ +import bundleAnalyzer from "@next/bundle-analyzer"; import pwa from "next-pwa"; +const withBundleAnalyzer = bundleAnalyzer({ + enabled: process.env.ANALYZE === "true", +}); + const withPWA = pwa({ dest: "public", disable: process.env.NODE_ENV != "production", @@ -53,4 +58,4 @@ const nextConfig = withPWA({ typescript: { ignoreBuildErrors: true }, }); -export default nextConfig; +export default withBundleAnalyzer(nextConfig); diff --git a/apps/web/package.json b/apps/web/package.json index 9d41af9b..c89a5bca 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,12 +33,14 @@ "@lexical/react": "^0.20.2", "@lexical/rich-text": "^0.20.2", "@marsidev/react-turnstile": "^1.3.1", + "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", @@ -53,32 +55,32 @@ "@svgr/webpack": "^8.1.0", "@tanstack/react-query": "5.90.2", "@tanstack/react-query-devtools": "5.90.2", - "@trpc/client": "^11.4.3", - "@trpc/react-query": "^11.4.3", - "@trpc/server": "^11.4.3", + "@trpc/client": "^11.9.0", + "@trpc/server": "^11.9.0", + "@trpc/tanstack-react-query": "^11.9.0", "cheerio": "^1.0.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "cmdk": "^1.1.1", "csv-parse": "^5.5.6", "date-fns": "^3.6.0", - "dayjs": "^1.11.10", "drizzle-orm": "^0.44.2", "fastest-levenshtein": "^1.0.16", "i18next": "^23.16.5", "i18next-resources-to-backend": "^1.2.1", "lexical": "^0.20.2", "lucide-react": "^0.501.0", - "next": "15.3.6", + "modern-screenshot": "^4.6.7", + "next": "15.3.8", "next-auth": "^4.24.11", "next-i18next": "^15.3.1", "next-pwa": "^5.6.0", - "next-themes": "^0.4.0", + "next-themes": "^0.4.6", "nuqs": "^2.4.3", "prettier": "^3.4.2", - "react": "^19.1.0", + "react": "^19.2.1", "react-day-picker": "^9.7.0", - "react-dom": "^19.1.0", + "react-dom": "^19.2.1", "react-draggable": "^4.5.0", "react-dropzone": "^14.2.3", "react-error-boundary": "^5.0.0", @@ -95,6 +97,7 @@ "remark-gfm": "^4.0.0", "request-ip": "^3.3.0", "sharp": "^0.33.3", + "sonner": "^2.0.7", "superjson": "^2.2.1", "tailwind-merge": "^2.2.1", "zod": "^3.24.2", @@ -104,6 +107,7 @@ "@karakeep/prettier-config": "workspace:^0.1.0", "@karakeep/tailwind-config": "workspace:^0.1.0", "@karakeep/tsconfig": "workspace:^0.1.0", + "@next/bundle-analyzer": "15.3.8", "@types/csv-parse": "^1.2.5", "@types/emoji-mart": "^3.0.14", "@types/react": "^19.1.6", diff --git a/apps/web/server/auth.ts b/apps/web/server/auth.ts index 833cf174..52c5e9b3 100644 --- a/apps/web/server/auth.ts +++ b/apps/web/server/auth.ts @@ -141,7 +141,6 @@ if (oauth.wellKnownUrl) { id: profile.sub, name: profile.name || profile.email, email: profile.email, - image: profile.picture, role: admin || firstUser ? "admin" : "user", }; }, 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; |
