diff options
| author | Mohamed Bassem <me@mbassem.com> | 2026-01-01 10:01:43 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-01 08:01:43 +0000 |
| commit | 3d652eee04d13ce992fbcce9a0fce53d52e99a07 (patch) | |
| tree | bb8e3a7a5e30be075b351a9ebd5de8f9975f35dc | |
| parent | 7a76216e5c971a300e9db32c93509b0376f6f47e (diff) | |
| download | karakeep-3d652eee04d13ce992fbcce9a0fce53d52e99a07.tar.zst | |
feat: add replace banner and attachment download (#2328)
* feat: add replace banner and attachment download
* add pdf preview in mobile app
* fix menu order
* fix comment
6 files changed, 205 insertions, 17 deletions
diff --git a/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx b/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx index e0b592d6..4478bdda 100644 --- a/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx +++ b/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx @@ -14,6 +14,7 @@ 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, @@ -33,6 +34,30 @@ 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, }: { diff --git a/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx b/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx index c7fd4be3..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; } @@ -64,6 +72,11 @@ 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 availableViewActions = viewActions.filter((action) => 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/web/components/dashboard/BulkBookmarksAction.tsx b/apps/web/components/dashboard/BulkBookmarksAction.tsx index bad76ff9..0e74b985 100644 --- a/apps/web/components/dashboard/BulkBookmarksAction.tsx +++ b/apps/web/components/dashboard/BulkBookmarksAction.tsx @@ -283,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, diff --git a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx index 696e1265..18ea3957 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { ChangeEvent, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -13,11 +13,14 @@ import { } from "@/components/ui/dropdown-menu"; import { toast } from "@/components/ui/sonner"; 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, @@ -34,13 +37,18 @@ import type { 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"; @@ -101,6 +109,47 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const [isTextEditorOpen, setTextEditorOpen] = useState(false); const [isEditBookmarkDialogOpen, setEditBookmarkDialogOpen] = useState(false); + const bannerFileInputRef = useRef<HTMLInputElement>(null); + + const { mutate: uploadAsset } = useUpload({ + onError: (e) => { + toast({ + description: e.error, + variant: "destructive", + }); + }, + }); + + const { mutate: attachAsset, isPending: isAttaching } = + useAttachBookmarkAsset({ + onSuccess: () => { + toast({ + description: "Banner has been attached!", + }); + }, + onError: (e) => { + toast({ + description: e.message, + variant: "destructive", + }); + }, + }); + + const { mutate: replaceAsset, isPending: isReplacing } = + useReplaceBookmarkAsset({ + onSuccess: () => { + toast({ + description: "Banner has been replaced!", + }); + }, + onError: (e) => { + toast({ + description: e.message, + variant: "destructive", + }); + }, + }); + const { listId } = useBookmarkGridContext() ?? {}; const withinListContext = useBookmarkListContext(); @@ -156,6 +205,40 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { 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) { + uploadAsset(file, { + onSuccess: (resp) => { + replaceAsset({ + bookmarkId: bookmark.id, + oldAssetId: existingBanner.id, + newAssetId: resp.assetId, + }); + }, + }); + } else { + uploadAsset(file, { + onSuccess: (resp) => { + attachAsset({ + bookmarkId: bookmark.id, + asset: { + id: resp.assetId, + assetType: "bannerImage", + }, + }); + }, + }); + } + } + }; + // Define action items array const actionItems: ActionItemType[] = [ { @@ -254,14 +337,6 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { }), }, { - id: "refresh", - title: t("actions.refresh"), - icon: <RotateCw className="mr-2 size-4" />, - visible: isOwner && bookmark.content.type === BookmarkTypes.LINK, - disabled: demoMode, - onClick: () => crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }), - }, - { id: "offline-copies", title: t("actions.offline_copies"), icon: <Archive className="mr-2 size-4" />, @@ -269,7 +344,7 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { items: [ { id: "download-full-page", - title: t("actions.download_full_page_archive"), + title: t("actions.preserve_offline_archive"), icon: <FileDown className="mr-2 size-4" />, visible: true, disabled: demoMode, @@ -293,6 +368,66 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { }); }, }, + { + 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(), + }, ], }, { @@ -390,6 +525,13 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { })} </DropdownMenuContent> </DropdownMenu> + <input + type="file" + ref={bannerFileInputRef} + onChange={handleBannerFileChange} + className="hidden" + accept=".jpg,.jpeg,.png,.webp" + /> </> ); } diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 7077a36b..20040a79 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -72,8 +72,10 @@ "refresh": "Refresh", "recrawl": "Recrawl", "offline_copies": "Offline Copies", - "download_full_page_archive": "Download Full Page Archive", + "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", @@ -101,6 +103,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", |
