diff options
Diffstat (limited to 'apps/web/components')
34 files changed, 1162 insertions, 193 deletions
diff --git a/apps/web/components/admin/BackgroundJobs.tsx b/apps/web/components/admin/BackgroundJobs.tsx index 217e2ad9..ac5885ef 100644 --- a/apps/web/components/admin/BackgroundJobs.tsx +++ b/apps/web/components/admin/BackgroundJobs.tsx @@ -127,7 +127,7 @@ function AdminActions() { variant="destructive" loading={isInferencePending} onClick={() => - reRunInferenceOnAllBookmarks({ taggingStatus: "failure" }) + reRunInferenceOnAllBookmarks({ type: "tag", status: "failure" }) } > {t("admin.actions.regenerate_ai_tags_for_failed_bookmarks_only")} @@ -135,12 +135,32 @@ function AdminActions() { <ActionButton variant="destructive" loading={isInferencePending} - onClick={() => reRunInferenceOnAllBookmarks({ taggingStatus: "all" })} + onClick={() => + reRunInferenceOnAllBookmarks({ type: "tag", status: "all" }) + } > {t("admin.actions.regenerate_ai_tags_for_all_bookmarks")} </ActionButton> <ActionButton variant="destructive" + loading={isInferencePending} + onClick={() => + reRunInferenceOnAllBookmarks({ type: "summarize", status: "failure" }) + } + > + {t("admin.actions.regenerate_ai_summaries_for_failed_bookmarks_only")} + </ActionButton> + <ActionButton + variant="destructive" + loading={isInferencePending} + onClick={() => + reRunInferenceOnAllBookmarks({ type: "summarize", status: "all" }) + } + > + {t("admin.actions.regenerate_ai_summaries_for_all_bookmarks")} + </ActionButton> + <ActionButton + variant="destructive" loading={isReindexPending} onClick={() => reindexBookmarks()} > diff --git a/apps/web/components/dashboard/SortOrderToggle.tsx b/apps/web/components/dashboard/SortOrderToggle.tsx index 8c0f617d..ba3385ac 100644 --- a/apps/web/components/dashboard/SortOrderToggle.tsx +++ b/apps/web/components/dashboard/SortOrderToggle.tsx @@ -1,3 +1,6 @@ +"use client"; + +import { useEffect } from "react"; import { ButtonWithTooltip } from "@/components/ui/button"; import { DropdownMenu, @@ -5,15 +8,26 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { useIsSearchPage } from "@/lib/hooks/bookmark-search"; import { useTranslation } from "@/lib/i18n/client"; import { useSortOrderStore } from "@/lib/store/useSortOrderStore"; -import { Check, SortAsc, SortDesc } from "lucide-react"; +import { Check, ListFilter, SortAsc, SortDesc } from "lucide-react"; export default function SortOrderToggle() { const { t } = useTranslation(); + const isInSearchPage = useIsSearchPage(); const { sortOrder: currentSort, setSortOrder } = useSortOrderStore(); + // also see related on page enter sortOrder.relevance init + // in apps/web/app/dashboard/search/page.tsx + useEffect(() => { + if (!isInSearchPage && currentSort === "relevance") { + // reset to default sort order + setSortOrder("desc"); + } + }, [isInSearchPage, currentSort]); + return ( <DropdownMenu> <DropdownMenuTrigger asChild> @@ -22,14 +36,24 @@ export default function SortOrderToggle() { delayDuration={100} variant="ghost" > - {currentSort === "asc" ? ( - <SortAsc size={18} /> - ) : ( - <SortDesc size={18} /> - )} + {currentSort === "relevance" && <ListFilter size={18} />} + {currentSort === "asc" && <SortAsc size={18} />} + {currentSort === "desc" && <SortDesc size={18} />} </ButtonWithTooltip> </DropdownMenuTrigger> <DropdownMenuContent className="w-fit"> + {isInSearchPage && ( + <DropdownMenuItem + className="cursor-pointer justify-between" + onClick={() => setSortOrder("relevance")} + > + <div className="flex items-center"> + <ListFilter size={16} className="mr-2" /> + <span>{t("actions.sort.relevant_first")}</span> + </div> + {currentSort === "relevance" && <Check className="ml-2 h-4 w-4" />} + </DropdownMenuItem> + )} <DropdownMenuItem className="cursor-pointer justify-between" onClick={() => setSortOrder("desc")} diff --git a/apps/web/components/dashboard/bookmarks/AssetCard.tsx b/apps/web/components/dashboard/bookmarks/AssetCard.tsx index 6fc8a723..c906f2a7 100644 --- a/apps/web/components/dashboard/bookmarks/AssetCard.tsx +++ b/apps/web/components/dashboard/bookmarks/AssetCard.tsx @@ -6,8 +6,8 @@ import { cn } from "@/lib/utils"; import { FileText } from "lucide-react"; import type { ZBookmarkTypeAsset } from "@karakeep/shared/types/bookmarks"; -import { getAssetUrl } from "@karakeep/shared-react/utils/assetUtils"; -import { getSourceUrl } from "@karakeep/shared-react/utils/bookmarkUtils"; +import { getAssetUrl } from "@karakeep/shared/utils/assetUtils"; +import { getSourceUrl } from "@karakeep/shared/utils/bookmarkUtils"; import { BookmarkLayoutAdaptingCard } from "./BookmarkLayoutAdaptingCard"; import FooterLinkURL from "./FooterLinkURL"; diff --git a/apps/web/components/dashboard/bookmarks/BookmarkCard.tsx b/apps/web/components/dashboard/bookmarks/BookmarkCard.tsx index 3c92e03e..4fc7d94a 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkCard.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkCard.tsx @@ -1,7 +1,7 @@ import { api } from "@/lib/trpc"; -import { isBookmarkStillLoading } from "@karakeep/shared-react/utils/bookmarkUtils"; import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; +import { isBookmarkStillLoading } from "@karakeep/shared/utils/bookmarkUtils"; import AssetCard from "./AssetCard"; import LinkCard from "./LinkCard"; diff --git a/apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx b/apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx new file mode 100644 index 00000000..a3e5d3b3 --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx @@ -0,0 +1,8 @@ +import dayjs from "dayjs"; + +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); +} diff --git a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx index a0437c71..4b511a3c 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx @@ -8,15 +8,15 @@ import { useBookmarkLayout, } from "@/lib/userLocalSettings/bookmarksLayout"; import { cn } from "@/lib/utils"; -import dayjs from "dayjs"; import { Check, Image as ImageIcon, NotebookPen } from "lucide-react"; import { useTheme } from "next-themes"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; -import { isBookmarkStillTagging } from "@karakeep/shared-react/utils/bookmarkUtils"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; +import { isBookmarkStillTagging } from "@karakeep/shared/utils/bookmarkUtils"; import BookmarkActionBar from "./BookmarkActionBar"; +import BookmarkFormattedCreatedAt from "./BookmarkFormattedCreatedAt"; import TagList from "./TagList"; interface Props { @@ -30,13 +30,6 @@ interface Props { wrapTags: boolean; } -function BookmarkFormattedCreatedAt({ bookmark }: { bookmark: ZBookmark }) { - const createdAt = dayjs(bookmark.createdAt); - const oneYearAgo = dayjs().subtract(1, "year"); - const formatString = createdAt.isAfter(oneYearAgo) ? "MMM D" : "MMM D, YYYY"; - return createdAt.format(formatString); -} - function BottomRow({ footer, bookmark, @@ -52,7 +45,7 @@ function BottomRow({ href={`/dashboard/preview/${bookmark.id}`} suppressHydrationWarning > - <BookmarkFormattedCreatedAt bookmark={bookmark} /> + <BookmarkFormattedCreatedAt createdAt={bookmark.createdAt} /> </Link> </div> <BookmarkActionBar bookmark={bookmark} /> @@ -239,7 +232,7 @@ function CompactView({ bookmark, title, footer, className }: Props) { suppressHydrationWarning className="shrink-0 gap-2 text-gray-500" > - <BookmarkFormattedCreatedAt bookmark={bookmark} /> + <BookmarkFormattedCreatedAt createdAt={bookmark.createdAt} /> </Link> </div> <BookmarkActionBar bookmark={bookmark} /> diff --git a/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx b/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx index debd5ad9..82e483a9 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx @@ -2,14 +2,18 @@ import MarkdownEditor from "@/components/ui/markdown/markdown-editor"; import { MarkdownReadonly } from "@/components/ui/markdown/markdown-readonly";
import { toast } from "@/components/ui/use-toast";
-import type { ZBookmarkTypeText } from "@karakeep/shared/types/bookmarks";
import { useUpdateBookmark } from "@karakeep/shared-react/hooks/bookmarks";
export function BookmarkMarkdownComponent({
children: bookmark,
readOnly = true,
}: {
- children: ZBookmarkTypeText;
+ children: {
+ id: string;
+ content: {
+ text: string;
+ };
+ };
readOnly?: boolean;
}) {
const { mutate: updateBookmarkMutator, isPending } = useUpdateBookmark({
diff --git a/apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx b/apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx index ab5d0364..f0ede24e 100644 --- a/apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx +++ b/apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx @@ -35,13 +35,13 @@ import { CalendarIcon } from "lucide-react"; import { useForm } from "react-hook-form"; import { useUpdateBookmark } from "@karakeep/shared-react/hooks/bookmarks"; -import { getBookmarkTitle } from "@karakeep/shared-react/utils/bookmarkUtils"; import { BookmarkTypes, ZBookmark, ZUpdateBookmarksRequest, zUpdateBookmarksRequestSchema, } from "@karakeep/shared/types/bookmarks"; +import { getBookmarkTitle } from "@karakeep/shared/utils/bookmarkUtils"; import { BookmarkTagsEditor } from "./BookmarkTagsEditor"; diff --git a/apps/web/components/dashboard/bookmarks/LinkCard.tsx b/apps/web/components/dashboard/bookmarks/LinkCard.tsx index ec224ca6..778166b5 100644 --- a/apps/web/components/dashboard/bookmarks/LinkCard.tsx +++ b/apps/web/components/dashboard/bookmarks/LinkCard.tsx @@ -2,6 +2,7 @@ import Image from "next/image"; import Link from "next/link"; +import { useUserSettings } from "@/lib/userSettings"; import type { ZBookmarkTypeLink } from "@karakeep/shared/types/bookmarks"; import { @@ -9,16 +10,30 @@ import { getBookmarkTitle, getSourceUrl, isBookmarkStillCrawling, -} from "@karakeep/shared-react/utils/bookmarkUtils"; +} from "@karakeep/shared/utils/bookmarkUtils"; import { BookmarkLayoutAdaptingCard } from "./BookmarkLayoutAdaptingCard"; import FooterLinkURL from "./FooterLinkURL"; +const useOnClickUrl = (bookmark: ZBookmarkTypeLink) => { + const userSettings = useUserSettings(); + return { + urlTarget: + userSettings.bookmarkClickAction === "open_original_link" + ? ("_blank" as const) + : ("_self" as const), + onClickUrl: + userSettings.bookmarkClickAction === "expand_bookmark_preview" + ? `/dashboard/preview/${bookmark.id}` + : bookmark.content.url, + }; +}; + function LinkTitle({ bookmark }: { bookmark: ZBookmarkTypeLink }) { - const link = bookmark.content; - const parsedUrl = new URL(link.url); + const { onClickUrl, urlTarget } = useOnClickUrl(bookmark); + const parsedUrl = new URL(bookmark.content.url); return ( - <Link href={link.url} target="_blank" rel="noreferrer"> + <Link href={onClickUrl} target={urlTarget} rel="noreferrer"> {getBookmarkTitle(bookmark) ?? parsedUrl.host} </Link> ); @@ -31,6 +46,7 @@ function LinkImage({ bookmark: ZBookmarkTypeLink; className?: string; }) { + const { onClickUrl, urlTarget } = useOnClickUrl(bookmark); const link = bookmark.content; const imgComponent = (url: string, unoptimized: boolean) => ( @@ -61,8 +77,8 @@ function LinkImage({ return ( <Link - href={link.url} - target="_blank" + href={onClickUrl} + target={urlTarget} rel="noreferrer" className={className} > diff --git a/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx b/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx index 717c98a1..b5e89a01 100644 --- a/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx +++ b/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx @@ -2,6 +2,7 @@ import React from "react"; import { ActionButton } from "@/components/ui/action-button"; 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"; import { ChevronUp, RefreshCw, Sparkles, Trash2 } from "lucide-react"; @@ -110,12 +111,15 @@ export default function SummarizeBookmarkArea({ }, }); + const clientConfig = useClientConfig(); if (bookmark.content.type !== BookmarkTypes.LINK) { return null; } if (bookmark.summary) { return <AISummary bookmarkId={bookmark.id} summary={bookmark.summary} />; + } else if (!clientConfig.inference.isConfigured) { + return null; } else { return ( <div className="flex w-full items-center gap-4"> diff --git a/apps/web/components/dashboard/bookmarks/TextCard.tsx b/apps/web/components/dashboard/bookmarks/TextCard.tsx index 0233357c..3be3a093 100644 --- a/apps/web/components/dashboard/bookmarks/TextCard.tsx +++ b/apps/web/components/dashboard/bookmarks/TextCard.tsx @@ -7,8 +7,8 @@ import { bookmarkLayoutSwitch } from "@/lib/userLocalSettings/bookmarksLayout"; import { cn } from "@/lib/utils"; import type { ZBookmarkTypeText } from "@karakeep/shared/types/bookmarks"; -import { getAssetUrl } from "@karakeep/shared-react/utils/assetUtils"; -import { getSourceUrl } from "@karakeep/shared-react/utils/bookmarkUtils"; +import { getAssetUrl } from "@karakeep/shared/utils/assetUtils"; +import { getSourceUrl } from "@karakeep/shared/utils/bookmarkUtils"; import { BookmarkLayoutAdaptingCard } from "./BookmarkLayoutAdaptingCard"; import FooterLinkURL from "./FooterLinkURL"; diff --git a/apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx b/apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx index da65b9d9..968d0326 100644 --- a/apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx +++ b/apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx @@ -23,7 +23,11 @@ export default function UpdatableBookmarksGrid({ showEditorCard?: boolean; itemsPerPage?: number; }) { - const sortOrder = useSortOrderStore((state) => state.sortOrder); + let sortOrder = useSortOrderStore((state) => state.sortOrder); + if (sortOrder === "relevance") { + // Relevance is not supported in the `getBookmarks` endpoint. + sortOrder = "desc"; + } const finalQuery = { ...query, sortOrder, includeContent: false }; diff --git a/apps/web/components/dashboard/lists/EditListModal.tsx b/apps/web/components/dashboard/lists/EditListModal.tsx index 68d32b0a..7a750c33 100644 --- a/apps/web/components/dashboard/lists/EditListModal.tsx +++ b/apps/web/components/dashboard/lists/EditListModal.tsx @@ -358,14 +358,16 @@ export function EditListModal({ value={field.value} onChange={field.onChange} placeholder={t("lists.search_query")} + endIcon={ + parsedSearchQuery ? ( + <QueryExplainerTooltip + className="stroke-foreground p-1" + parsedSearchQuery={parsedSearchQuery} + /> + ) : undefined + } /> </FormControl> - {parsedSearchQuery && ( - <QueryExplainerTooltip - className="translate-1/2 absolute right-1.5 top-2 stroke-foreground p-0.5" - parsedSearchQuery={parsedSearchQuery} - /> - )} </div> <FormDescription> <Link diff --git a/apps/web/components/dashboard/lists/ListOptions.tsx b/apps/web/components/dashboard/lists/ListOptions.tsx index 9a979686..7e020374 100644 --- a/apps/web/components/dashboard/lists/ListOptions.tsx +++ b/apps/web/components/dashboard/lists/ListOptions.tsx @@ -5,14 +5,24 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { useShowArchived } from "@/components/utils/useShowArchived"; import { useTranslation } from "@/lib/i18n/client"; -import { FolderInput, Pencil, Plus, Trash2 } from "lucide-react"; +import { + FolderInput, + Pencil, + Plus, + Share, + Square, + SquareCheck, + Trash2, +} from "lucide-react"; import { ZBookmarkList } from "@karakeep/shared/types/lists"; import { EditListModal } from "../lists/EditListModal"; import DeleteListConfirmationDialog from "./DeleteListConfirmationDialog"; import { MergeListModal } from "./MergeListModal"; +import { ShareListModal } from "./ShareListModal"; export function ListOptions({ list, @@ -26,14 +36,21 @@ export function ListOptions({ children?: React.ReactNode; }) { const { t } = useTranslation(); + const { showArchived, onClickShowArchived } = useShowArchived(); const [deleteListDialogOpen, setDeleteListDialogOpen] = useState(false); const [newNestedListModalOpen, setNewNestedListModalOpen] = useState(false); const [mergeListModalOpen, setMergeListModalOpen] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false); + const [shareModalOpen, setShareModalOpen] = useState(false); return ( <DropdownMenu open={isOpen} onOpenChange={onOpenChange}> + <ShareListModal + open={shareModalOpen} + setOpen={setShareModalOpen} + list={list} + /> <EditListModal open={newNestedListModalOpen} setOpen={setNewNestedListModalOpen} @@ -67,6 +84,13 @@ export function ListOptions({ </DropdownMenuItem> <DropdownMenuItem className="flex gap-2" + onClick={() => setShareModalOpen(true)} + > + <Share className="size-4" /> + <span>{t("lists.share_list")}</span> + </DropdownMenuItem> + <DropdownMenuItem + className="flex gap-2" onClick={() => setNewNestedListModalOpen(true)} > <Plus className="size-4" /> @@ -79,6 +103,14 @@ export function ListOptions({ <FolderInput className="size-4" /> <span>{t("lists.merge_list")}</span> </DropdownMenuItem> + <DropdownMenuItem className="flex gap-2" onClick={onClickShowArchived}> + {showArchived ? ( + <SquareCheck className="size-4" /> + ) : ( + <Square className="size-4" /> + )} + <span>{t("actions.toggle_show_archived")}</span> + </DropdownMenuItem> <DropdownMenuItem className="flex gap-2" onClick={() => setDeleteListDialogOpen(true)} diff --git a/apps/web/components/dashboard/lists/PublicListLink.tsx b/apps/web/components/dashboard/lists/PublicListLink.tsx new file mode 100644 index 00000000..9cd1f795 --- /dev/null +++ b/apps/web/components/dashboard/lists/PublicListLink.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { CopyBtnV2 } from "@/components/ui/copy-button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { useClientConfig } from "@/lib/clientConfig"; +import { useTranslation } from "react-i18next"; + +import { useEditBookmarkList } from "@karakeep/shared-react/hooks/lists"; +import { ZBookmarkList } from "@karakeep/shared/types/lists"; + +export default function PublicListLink({ list }: { list: ZBookmarkList }) { + const { t } = useTranslation(); + const clientConfig = useClientConfig(); + + const { mutate: editList, isPending: isLoading } = useEditBookmarkList(); + + const publicListUrl = `${clientConfig.publicUrl}/public/lists/${list.id}`; + const isPublic = list.public; + + return ( + <> + {/* Public List Toggle */} + <div className="flex items-center justify-between"> + <div className="space-y-1"> + <Label htmlFor="public-toggle" className="text-sm font-medium"> + {t("lists.public_list.title")} + </Label> + <p className="text-xs text-muted-foreground"> + {t("lists.public_list.description")} + </p> + </div> + <Switch + id="public-toggle" + checked={isPublic} + disabled={isLoading || !!clientConfig.demoMode} + onCheckedChange={(checked) => { + editList({ + listId: list.id, + public: checked, + }); + }} + /> + </div> + + {/* Share URL - only show when public */} + {isPublic && ( + <> + <div className="space-y-3"> + <Label className="text-sm font-medium"> + {t("lists.public_list.share_link")} + </Label> + <div className="flex items-center space-x-2"> + <Input + value={publicListUrl} + readOnly + className="flex-1 text-sm" + /> + <CopyBtnV2 getStringToCopy={() => publicListUrl} /> + </div> + </div> + </> + )} + </> + ); +} diff --git a/apps/web/components/dashboard/lists/RssLink.tsx b/apps/web/components/dashboard/lists/RssLink.tsx new file mode 100644 index 00000000..1be48681 --- /dev/null +++ b/apps/web/components/dashboard/lists/RssLink.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { CopyBtnV2 } from "@/components/ui/copy-button"; +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 { Loader2, RotateCcw } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +export default function RssLink({ listId }: { listId: string }) { + const { t } = useTranslation(); + const clientConfig = useClientConfig(); + const apiUtils = api.useUtils(); + + const { mutate: regenRssToken, isPending: isRegenPending } = + api.lists.regenRssToken.useMutation({ + onSuccess: () => { + apiUtils.lists.getRssToken.invalidate({ listId }); + }, + }); + const { mutate: clearRssToken, isPending: isClearPending } = + api.lists.clearRssToken.useMutation({ + onSuccess: () => { + apiUtils.lists.getRssToken.invalidate({ listId }); + }, + }); + const { data: rssToken, isLoading: isTokenLoading } = + api.lists.getRssToken.useQuery({ listId }); + + const rssUrl = useMemo(() => { + if (!rssToken || !rssToken.token) { + return null; + } + return `${clientConfig.publicApiUrl}/v1/rss/lists/${listId}?token=${rssToken.token}`; + }, [rssToken]); + + const rssEnabled = rssUrl !== null; + + return ( + <> + {/* RSS Feed Toggle */} + <div className="flex items-center justify-between"> + <div className="space-y-1"> + <Label htmlFor="rss-toggle" className="text-sm font-medium"> + {t("lists.rss.title")} + </Label> + <p className="text-xs text-muted-foreground"> + {t("lists.rss.description")} + </p> + </div> + <Switch + id="rss-toggle" + checked={rssEnabled} + onCheckedChange={(checked) => + checked ? regenRssToken({ listId }) : clearRssToken({ listId }) + } + disabled={ + isTokenLoading || + isClearPending || + isRegenPending || + !!clientConfig.demoMode + } + /> + </div> + {/* RSS URL - only show when RSS is enabled */} + {rssEnabled && ( + <div className="space-y-3"> + <Label className="text-sm font-medium"> + {t("lists.rss.feed_url")} + </Label> + <div className="flex items-center space-x-2"> + <Input value={rssUrl} readOnly className="flex-1 text-sm" /> + <CopyBtnV2 getStringToCopy={() => rssUrl} /> + <Button + variant="outline" + size="sm" + onClick={() => regenRssToken({ listId })} + disabled={isRegenPending} + > + {isRegenPending ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : ( + <RotateCcw className="h-4 w-4" /> + )} + </Button> + </div> + </div> + )} + </> + ); +} diff --git a/apps/web/components/dashboard/lists/ShareListModal.tsx b/apps/web/components/dashboard/lists/ShareListModal.tsx new file mode 100644 index 00000000..16668e67 --- /dev/null +++ b/apps/web/components/dashboard/lists/ShareListModal.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { useTranslation } from "@/lib/i18n/client"; +import { DialogDescription } from "@radix-ui/react-dialog"; + +import { ZBookmarkList } from "@karakeep/shared/types/lists"; + +import PublicListLink from "./PublicListLink"; +import RssLink from "./RssLink"; + +export function ShareListModal({ + open: userOpen, + setOpen: userSetOpen, + list, + children, +}: { + open?: boolean; + setOpen?: (v: boolean) => void; + list: ZBookmarkList; + children?: React.ReactNode; +}) { + const { t } = useTranslation(); + if ( + (userOpen !== undefined && !userSetOpen) || + (userOpen === undefined && userSetOpen) + ) { + throw new Error("You must provide both open and setOpen or neither"); + } + const [customOpen, customSetOpen] = useState(false); + const [open, setOpen] = [ + userOpen ?? customOpen, + userSetOpen ?? customSetOpen, + ]; + + return ( + <Dialog + open={open} + onOpenChange={(s) => { + setOpen(s); + }} + > + {children && <DialogTrigger asChild>{children}</DialogTrigger>} + <DialogContent className="max-w-xl"> + <DialogHeader> + <DialogTitle>{t("lists.share_list")}</DialogTitle> + </DialogHeader> + <DialogDescription className="mt-4 space-y-6"> + <PublicListLink list={list} /> + <RssLink listId={list.id} /> + </DialogDescription> + <DialogFooter className="sm:justify-end"> + <DialogClose asChild> + <Button type="button" variant="secondary"> + {t("actions.close")} + </Button> + </DialogClose> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/components/dashboard/preview/AssetContentSection.tsx b/apps/web/components/dashboard/preview/AssetContentSection.tsx index fd299320..5cab86bd 100644 --- a/apps/web/components/dashboard/preview/AssetContentSection.tsx +++ b/apps/web/components/dashboard/preview/AssetContentSection.tsx @@ -11,8 +11,8 @@ import { } from "@/components/ui/select"; import { useTranslation } from "@/lib/i18n/client"; -import { getAssetUrl } from "@karakeep/shared-react/utils/assetUtils"; import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; +import { getAssetUrl } from "@karakeep/shared/utils/assetUtils"; // 20 MB const BIG_FILE_SIZE = 20 * 1024 * 1024; diff --git a/apps/web/components/dashboard/preview/AttachmentBox.tsx b/apps/web/components/dashboard/preview/AttachmentBox.tsx index 15acd799..674f151c 100644 --- a/apps/web/components/dashboard/preview/AttachmentBox.tsx +++ b/apps/web/components/dashboard/preview/AttachmentBox.tsx @@ -19,8 +19,8 @@ import { useDetachBookmarkAsset, useReplaceBookmarkAsset, } from "@karakeep/shared-react/hooks/assets"; -import { getAssetUrl } from "@karakeep/shared-react/utils/assetUtils"; import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; +import { getAssetUrl } from "@karakeep/shared/utils/assetUtils"; import { humanFriendlyNameForAssertType, isAllowedToAttachAsset, diff --git a/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx b/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx index a3b34f9a..dc446112 100644 --- a/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx +++ b/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx @@ -19,6 +19,7 @@ interface ColorPickerMenuProps { onDelete?: () => void; selectedHighlight: Highlight | null; onClose: () => void; + isMobile: boolean; } const ColorPickerMenu: React.FC<ColorPickerMenuProps> = ({ @@ -27,6 +28,7 @@ const ColorPickerMenu: React.FC<ColorPickerMenuProps> = ({ onDelete, selectedHighlight, onClose, + isMobile, }) => { return ( <Popover @@ -44,7 +46,10 @@ const ColorPickerMenu: React.FC<ColorPickerMenuProps> = ({ top: position?.y, }} /> - <PopoverContent side="top" className="flex w-fit items-center gap-1 p-2"> + <PopoverContent + side={isMobile ? "bottom" : "top"} + className="flex w-fit items-center gap-1 p-2" + > {SUPPORTED_HIGHLIGHT_COLORS.map((color) => ( <Button size="none" @@ -113,6 +118,11 @@ function BookmarkHTMLHighlighter({ const [selectedHighlight, setSelectedHighlight] = useState<Highlight | null>( null, ); + const isMobile = useState( + () => + typeof window !== "undefined" && + window.matchMedia("(pointer: coarse)").matches, + )[0]; // Apply existing highlights when component mounts or highlights change useEffect(() => { @@ -160,7 +170,7 @@ function BookmarkHTMLHighlighter({ window.getSelection()?.addRange(newRange); }, [pendingHighlight, contentRef]); - const handleMouseUp = (e: React.MouseEvent) => { + const handlePointerUp = (e: React.PointerEvent) => { const selection = window.getSelection(); // Check if we clicked on an existing highlight @@ -192,11 +202,11 @@ function BookmarkHTMLHighlighter({ return; } - // Position the menu above the selection + // Position the menu based on device type const rect = range.getBoundingClientRect(); setMenuPosition({ - x: rect.left + rect.width / 2, // Center the menu - y: rect.top, + x: rect.left + rect.width / 2, // Center the menu horizontally + y: isMobile ? rect.bottom : rect.top, // Position below on mobile, above otherwise }); // Store the highlight for later use @@ -333,7 +343,7 @@ function BookmarkHTMLHighlighter({ role="presentation" ref={contentRef} dangerouslySetInnerHTML={{ __html: htmlContent }} - onMouseUp={handleMouseUp} + onPointerUp={handlePointerUp} className={className} /> <ColorPickerMenu @@ -342,6 +352,7 @@ function BookmarkHTMLHighlighter({ onDelete={handleDelete} selectedHighlight={selectedHighlight} onClose={closeColorPicker} + isMobile={isMobile} /> </div> ); diff --git a/apps/web/components/dashboard/preview/BookmarkPreview.tsx b/apps/web/components/dashboard/preview/BookmarkPreview.tsx index df09f687..e213b9cb 100644 --- a/apps/web/components/dashboard/preview/BookmarkPreview.tsx +++ b/apps/web/components/dashboard/preview/BookmarkPreview.tsx @@ -1,11 +1,12 @@ "use client"; -import React from "react"; +import { useState } from "react"; import Link from "next/link"; import { BookmarkTagsEditor } from "@/components/dashboard/bookmarks/BookmarkTagsEditor"; import { FullPageSpinner } from "@/components/ui/full-page-spinner"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, @@ -17,13 +18,13 @@ import { useTranslation } from "@/lib/i18n/client"; import { api } from "@/lib/trpc"; import { CalendarDays, ExternalLink } from "lucide-react"; +import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; import { getBookmarkTitle, getSourceUrl, isBookmarkStillCrawling, isBookmarkStillLoading, -} from "@karakeep/shared-react/utils/bookmarkUtils"; -import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; +} from "@karakeep/shared/utils/bookmarkUtils"; import SummarizeBookmarkArea from "../bookmarks/SummarizeBookmarkArea"; import ActionBar from "./ActionBar"; @@ -68,6 +69,8 @@ export default function BookmarkPreview({ initialData?: ZBookmark; }) { const { t } = useTranslation(); + const [activeTab, setActiveTab] = useState<string>("content"); + const { data: bookmark } = api.bookmarks.getBookmark.useQuery( { bookmarkId, @@ -111,45 +114,86 @@ export default function BookmarkPreview({ const sourceUrl = getSourceUrl(bookmark); const title = getBookmarkTitle(bookmark); - return ( - <div className="grid h-full grid-rows-3 gap-2 overflow-hidden bg-background lg:grid-cols-3 lg:grid-rows-none"> - <div className="row-span-2 h-full w-full overflow-auto p-2 md:col-span-2 lg:row-auto"> - {isBookmarkStillCrawling(bookmark) ? <ContentLoading /> : content} - </div> - <div className="row-span-1 flex flex-col gap-4 overflow-auto bg-accent p-4 md:col-span-2 lg:col-span-1 lg:row-auto"> - <div className="flex w-full flex-col items-center justify-center gap-y-2"> - <div className="flex w-full items-center justify-center gap-2"> - <p className="line-clamp-2 text-ellipsis break-words text-lg"> - {title === undefined || title === "" ? "Untitled" : title} - </p> - </div> - {sourceUrl && ( - <Link - href={sourceUrl} - target="_blank" - className="flex items-center gap-2 text-gray-400" - > - <span>{t("preview.view_original")}</span> - <ExternalLink /> - </Link> - )} - <Separator /> + // Common content for both layouts + const contentSection = isBookmarkStillCrawling(bookmark) ? ( + <ContentLoading /> + ) : ( + content + ); + + const detailsSection = ( + <div className="flex flex-col gap-4"> + <div className="flex w-full flex-col items-center justify-center gap-y-2"> + <div className="flex w-full items-center justify-center gap-2"> + <p className="line-clamp-2 text-ellipsis break-words text-lg"> + {title === undefined || title === "" ? "Untitled" : title} + </p> </div> + {sourceUrl && ( + <Link + href={sourceUrl} + target="_blank" + className="flex items-center gap-2 text-gray-400" + > + <span>{t("preview.view_original")}</span> + <ExternalLink /> + </Link> + )} + <Separator /> + </div> + <CreationTime createdAt={bookmark.createdAt} /> + <SummarizeBookmarkArea bookmark={bookmark} /> + <div className="flex items-center gap-4"> + <p className="text-sm text-gray-400">{t("common.tags")}</p> + <BookmarkTagsEditor bookmark={bookmark} /> + </div> + <div className="flex gap-4"> + <p className="pt-2 text-sm text-gray-400">{t("common.note")}</p> + <NoteEditor bookmark={bookmark} /> + </div> + <AttachmentBox bookmark={bookmark} /> + <HighlightsBox bookmarkId={bookmark.id} /> + <ActionBar bookmark={bookmark} /> + </div> + ); - <CreationTime createdAt={bookmark.createdAt} /> - <SummarizeBookmarkArea bookmark={bookmark} /> - <div className="flex items-center gap-4"> - <p className="text-sm text-gray-400">{t("common.tags")}</p> - <BookmarkTagsEditor bookmark={bookmark} /> + return ( + <> + {/* Render original layout for wide screens */} + <div className="hidden h-full grid-cols-3 overflow-hidden bg-background lg:grid"> + <div className="col-span-2 h-full w-full overflow-auto p-2"> + {contentSection} </div> - <div className="flex gap-4"> - <p className="pt-2 text-sm text-gray-400">{t("common.note")}</p> - <NoteEditor bookmark={bookmark} /> + <div className="flex flex-col gap-4 overflow-auto bg-accent p-4"> + {detailsSection} </div> - <AttachmentBox bookmark={bookmark} /> - <HighlightsBox bookmarkId={bookmark.id} /> - <ActionBar bookmark={bookmark} /> </div> - </div> + + {/* Render tabbed layout for narrow/vertical screens */} + <Tabs + value={activeTab} + onValueChange={setActiveTab} + className="flex h-full w-full flex-col overflow-hidden lg:hidden" + > + <TabsList + className={`sticky top-0 z-10 grid h-auto w-full grid-cols-2`} + > + <TabsTrigger value="content">{t("preview.tabs.content")}</TabsTrigger> + <TabsTrigger value="details">{t("preview.tabs.details")}</TabsTrigger> + </TabsList> + <TabsContent + value="content" + className="h-full flex-1 overflow-hidden overflow-y-auto bg-background p-2 data-[state=inactive]:hidden" + > + {contentSection} + </TabsContent> + <TabsContent + value="details" + className="h-full overflow-y-auto bg-accent p-4 data-[state=inactive]:hidden" + > + {detailsSection} + </TabsContent> + </Tabs> + </> ); } diff --git a/apps/web/components/dashboard/preview/TextContentSection.tsx b/apps/web/components/dashboard/preview/TextContentSection.tsx index 0c1aae67..4e33bb92 100644 --- a/apps/web/components/dashboard/preview/TextContentSection.tsx +++ b/apps/web/components/dashboard/preview/TextContentSection.tsx @@ -3,8 +3,8 @@ import { BookmarkMarkdownComponent } from "@/components/dashboard/bookmarks/Book import { ScrollArea } from "@radix-ui/react-scroll-area"; import type { ZBookmarkTypeText } from "@karakeep/shared/types/bookmarks"; -import { getAssetUrl } from "@karakeep/shared-react/utils/assetUtils"; import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; +import { getAssetUrl } from "@karakeep/shared/utils/assetUtils"; export function TextContentSection({ bookmark }: { bookmark: ZBookmark }) { if (bookmark.content.type != BookmarkTypes.TEXT) { diff --git a/apps/web/components/dashboard/search/SearchInput.tsx b/apps/web/components/dashboard/search/SearchInput.tsx index c58542bf..e60c460c 100644 --- a/apps/web/components/dashboard/search/SearchInput.tsx +++ b/apps/web/components/dashboard/search/SearchInput.tsx @@ -100,7 +100,7 @@ const SearchInput = React.forwardRef< </Button> )} <Input - startIcon={SearchIcon} + startIcon={<SearchIcon size={18} className="text-muted-foreground" />} ref={inputRef} value={value} onChange={onChange} diff --git a/apps/web/components/dashboard/sidebar/AllLists.tsx b/apps/web/components/dashboard/sidebar/AllLists.tsx index 4c97ffae..50a06106 100644 --- a/apps/web/components/dashboard/sidebar/AllLists.tsx +++ b/apps/web/components/dashboard/sidebar/AllLists.tsx @@ -76,7 +76,7 @@ export default function AllLists({ } name={node.item.name} path={`/dashboard/lists/${node.item.id}`} - className="px-0.5" + className="group px-0.5" right={ <ListOptions onOpenChange={(open) => { @@ -88,34 +88,32 @@ export default function AllLists({ }} list={node.item} > - <Button size="none" variant="ghost"> - <div 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", - )} - /> + <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", + )} + /> - <Badge - variant="outline" - className={cn( - "font-normal opacity-100 transition-opacity duration-100 group-hover:opacity-0", - selectedListId == node.item.id || - numBookmarks === undefined - ? "opacity-0" - : "opacity-100", - )} - > - {numBookmarks} - </Badge> - </div> + <Badge + variant="outline" + className={cn( + "font-normal opacity-100 transition-opacity duration-100 group-hover:opacity-0", + selectedListId == node.item.id || + numBookmarks === undefined + ? "opacity-0" + : "opacity-100", + )} + > + {numBookmarks} + </Badge> </Button> </ListOptions> } - linkClassName="group py-0.5" + linkClassName="py-0.5" style={{ marginLeft: `${level * 1}rem` }} /> )} diff --git a/apps/web/components/dashboard/tags/TagOptions.tsx b/apps/web/components/dashboard/tags/TagOptions.tsx index 8d8cc9db..1419e6c3 100644 --- a/apps/web/components/dashboard/tags/TagOptions.tsx +++ b/apps/web/components/dashboard/tags/TagOptions.tsx @@ -7,8 +7,9 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { useShowArchived } from "@/components/utils/useShowArchived"; import { useTranslation } from "@/lib/i18n/client"; -import { Combine, Trash2 } from "lucide-react"; +import { Combine, Square, SquareCheck, Trash2 } from "lucide-react"; import DeleteTagConfirmationDialog from "./DeleteTagConfirmationDialog"; import { MergeTagModal } from "./MergeTagModal"; @@ -21,6 +22,8 @@ export function TagOptions({ children?: React.ReactNode; }) { const { t } = useTranslation(); + const { showArchived, onClickShowArchived } = useShowArchived(); + const [deleteTagDialogOpen, setDeleteTagDialogOpen] = useState(false); const [mergeTagDialogOpen, setMergeTagDialogOpen] = useState(false); @@ -45,7 +48,14 @@ export function TagOptions({ <Combine className="size-4" /> <span>{t("actions.merge")}</span> </DropdownMenuItem> - + <DropdownMenuItem className="flex gap-2" onClick={onClickShowArchived}> + {showArchived ? ( + <SquareCheck className="size-4" /> + ) : ( + <Square className="size-4" /> + )} + <span>{t("actions.toggle_show_archived")}</span> + </DropdownMenuItem> <DropdownMenuItem className="flex gap-2" onClick={() => setDeleteTagDialogOpen(true)} diff --git a/apps/web/components/dashboard/tags/TagPill.tsx b/apps/web/components/dashboard/tags/TagPill.tsx index c6b08d64..91bd8504 100644 --- a/apps/web/components/dashboard/tags/TagPill.tsx +++ b/apps/web/components/dashboard/tags/TagPill.tsx @@ -81,6 +81,7 @@ export function TagPill({ } href={`/dashboard/tags/${id}`} data-id={id} + draggable={false} > {name} <Separator orientation="vertical" /> {count} </Link> diff --git a/apps/web/components/public/lists/PublicBookmarkGrid.tsx b/apps/web/components/public/lists/PublicBookmarkGrid.tsx new file mode 100644 index 00000000..038ac3ae --- /dev/null +++ b/apps/web/components/public/lists/PublicBookmarkGrid.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { useEffect, useMemo } from "react"; +import Link from "next/link"; +import BookmarkFormattedCreatedAt from "@/components/dashboard/bookmarks/BookmarkFormattedCreatedAt"; +import { BookmarkMarkdownComponent } from "@/components/dashboard/bookmarks/BookmarkMarkdownComponent"; +import FooterLinkURL from "@/components/dashboard/bookmarks/FooterLinkURL"; +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 { Expand, FileIcon, ImageIcon } from "lucide-react"; +import { useInView } from "react-intersection-observer"; +import Masonry from "react-masonry-css"; +import resolveConfig from "tailwindcss/resolveConfig"; + +import { + BookmarkTypes, + ZPublicBookmark, +} from "@karakeep/shared/types/bookmarks"; +import { ZCursor } from "@karakeep/shared/types/pagination"; + +function TagPill({ tag }: { tag: string }) { + return ( + <div + className={cn( + badgeVariants({ variant: "secondary" }), + "text-nowrap font-light text-gray-700 hover:bg-foreground hover:text-secondary dark:text-gray-400", + )} + key={tag} + > + {tag} + </div> + ); +} + +function BookmarkCard({ bookmark }: { bookmark: ZPublicBookmark }) { + const renderContent = () => { + switch (bookmark.content.type) { + case BookmarkTypes.LINK: + return ( + <div className="space-y-2"> + {bookmark.bannerImageUrl && ( + <div className="aspect-video w-full overflow-hidden rounded bg-gray-100"> + <Link href={bookmark.content.url} target="_blank"> + <img + src={bookmark.bannerImageUrl} + alt={bookmark.title ?? "Link preview"} + className="h-full w-full object-cover" + /> + </Link> + </div> + )} + <div className="space-y-2"> + <Link + href={bookmark.content.url} + target="_blank" + className="line-clamp-2 text-ellipsis text-lg font-medium leading-tight text-gray-900" + > + {bookmark.title} + </Link> + </div> + </div> + ); + + case BookmarkTypes.TEXT: + return ( + <div className="space-y-2"> + {bookmark.title && ( + <h3 className="line-clamp-2 text-ellipsis text-lg font-medium leading-tight text-gray-900"> + {bookmark.title} + </h3> + )} + <div className="group relative max-h-64 overflow-hidden"> + <BookmarkMarkdownComponent readOnly={true}> + {{ + id: bookmark.id, + content: { + text: bookmark.content.text, + }, + }} + </BookmarkMarkdownComponent> + <Dialog> + <DialogTrigger className="absolute bottom-2 right-2 z-50 h-4 w-4 opacity-0 group-hover:opacity-100"> + <Expand className="h-4 w-4" /> + </DialogTrigger> + <DialogContent className="max-h-96 max-w-3xl overflow-auto"> + <BookmarkMarkdownComponent readOnly={true}> + {{ + id: bookmark.id, + content: { + text: bookmark.content.text, + }, + }} + </BookmarkMarkdownComponent> + </DialogContent> + </Dialog> + </div> + </div> + ); + + case BookmarkTypes.ASSET: + return ( + <div className="space-y-2"> + {bookmark.bannerImageUrl ? ( + <div className="aspect-video w-full overflow-hidden rounded bg-gray-100"> + <Link href={bookmark.content.assetUrl}> + <img + src={bookmark.bannerImageUrl} + alt={bookmark.title ?? "Asset preview"} + className="h-full w-full object-cover" + /> + </Link> + </div> + ) : ( + <div className="flex aspect-video w-full items-center justify-center overflow-hidden rounded bg-gray-100"> + {bookmark.content.assetType === "image" ? ( + <ImageIcon className="h-8 w-8 text-gray-400" /> + ) : ( + <FileIcon className="h-8 w-8 text-gray-400" /> + )} + </div> + )} + <div className="space-y-1"> + <Link + href={bookmark.content.assetUrl} + target="_blank" + className="line-clamp-2 text-ellipsis text-lg font-medium leading-tight text-gray-900" + > + {bookmark.title} + </Link> + </div> + </div> + ); + } + }; + + return ( + <Card className="group mb-3 border-0 shadow-sm transition-all duration-200 hover:shadow-lg"> + <CardContent className="p-3"> + {renderContent()} + + {/* Tags */} + {bookmark.tags.length > 0 && ( + <div className="mt-2 flex flex-wrap gap-1"> + {bookmark.tags.map((tag, index) => ( + <TagPill key={index} tag={tag} /> + ))} + </div> + )} + + {/* Footer */} + <div className="mt-3 flex items-center justify-between pt-2"> + <div className="flex items-center gap-2 text-xs text-gray-500"> + {bookmark.content.type === BookmarkTypes.LINK && ( + <> + <FooterLinkURL url={bookmark.content.url} /> + <span>•</span> + </> + )} + <BookmarkFormattedCreatedAt createdAt={bookmark.createdAt} /> + </div> + </div> + </CardContent> + </Card> + ); +} + +function getBreakpointConfig() { + const fullConfig = resolveConfig(tailwindConfig); + + const breakpointColumnsObj: { [key: number]: number; default: number } = { + default: 3, + }; + breakpointColumnsObj[parseInt(fullConfig.theme.screens.lg)] = 2; + breakpointColumnsObj[parseInt(fullConfig.theme.screens.md)] = 1; + breakpointColumnsObj[parseInt(fullConfig.theme.screens.sm)] = 1; + return breakpointColumnsObj; +} + +export default function PublicBookmarkGrid({ + bookmarks: initialBookmarks, + nextCursor, + list, +}: { + list: { + id: string; + name: string; + description: string | null | undefined; + icon: string; + numItems: number; + }; + bookmarks: ZPublicBookmark[]; + nextCursor: ZCursor | null; +}) { + 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, + }, + ); + + useEffect(() => { + if (loadMoreButtonInView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [loadMoreButtonInView]); + + const breakpointConfig = useMemo(() => getBreakpointConfig(), []); + + const bookmarks = useMemo(() => { + return data.pages.flatMap((b) => b.bookmarks); + }, [data]); + return ( + <> + <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> + {bookmarks.map((bookmark) => ( + <BookmarkCard key={bookmark.id} bookmark={bookmark} /> + ))} + </Masonry> + {hasNextPage && ( + <div className="flex justify-center"> + <ActionButton + ref={loadMoreRef} + ignoreDemoMode={true} + loading={isFetchingNextPage} + onClick={() => fetchNextPage()} + variant="ghost" + > + Load More + </ActionButton> + </div> + )} + </> + ); +} diff --git a/apps/web/components/public/lists/PublicListHeader.tsx b/apps/web/components/public/lists/PublicListHeader.tsx new file mode 100644 index 00000000..1f016351 --- /dev/null +++ b/apps/web/components/public/lists/PublicListHeader.tsx @@ -0,0 +1,17 @@ +export default function PublicListHeader({ + list, +}: { + list: { + id: string; + numItems: number; + }; +}) { + return ( + <div className="flex w-full justify-between"> + <span /> + <p className="text-xs font-light uppercase text-gray-500"> + {list.numItems} bookmarks + </p> + </div> + ); +} diff --git a/apps/web/components/settings/FeedSettings.tsx b/apps/web/components/settings/FeedSettings.tsx index ff8590c9..fa019cf6 100644 --- a/apps/web/components/settings/FeedSettings.tsx +++ b/apps/web/components/settings/FeedSettings.tsx @@ -13,6 +13,7 @@ import { } from "@/components/ui/form"; import { FullPageSpinner } from "@/components/ui/full-page-spinner"; import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; import { api } from "@/lib/trpc"; @@ -70,6 +71,7 @@ export function FeedsEditorDialog() { defaultValues: { name: "", url: "", + enabled: true, }, }); @@ -199,12 +201,16 @@ export function EditFeedDialog({ feed }: { feed: ZFeed }) { }); return ( <Dialog open={open} onOpenChange={setOpen}> - <DialogTrigger asChild> - <Button variant="secondary"> - <Edit className="mr-2 size-4" /> - {t("actions.edit")} - </Button> - </DialogTrigger> + <Tooltip> + <TooltipTrigger asChild> + <DialogTrigger asChild> + <Button variant="ghost"> + <Edit className="size-4" /> + </Button> + </DialogTrigger> + </TooltipTrigger> + <TooltipContent>{t("actions.edit")}</TooltipContent> + </Tooltip> <DialogContent> <DialogHeader> <DialogTitle>Edit Feed</DialogTitle> @@ -309,6 +315,27 @@ export function FeedRow({ feed }: { feed: ZFeed }) { }, }); + 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 handleToggle = (checked: boolean) => { + updateFeedEnabled({ feedId: feed.id, enabled: checked }); + }; + return ( <TableRow> <TableCell> @@ -319,7 +346,12 @@ export function FeedRow({ feed }: { feed: ZFeed }) { {feed.name} </Link> </TableCell> - <TableCell>{feed.url}</TableCell> + <TableCell + className="max-w-64 overflow-clip text-ellipsis" + title={feed.url} + > + {feed.url} + </TableCell> <TableCell>{feed.lastFetchedAt?.toLocaleString()}</TableCell> <TableCell> {feed.lastFetchedStatus === "success" ? ( @@ -337,16 +369,21 @@ export function FeedRow({ feed }: { feed: ZFeed }) { )} </TableCell> <TableCell className="flex items-center gap-2"> + <Switch checked={feed.enabled} onCheckedChange={handleToggle} /> <EditFeedDialog feed={feed} /> - <ActionButton - loading={isFetching} - variant="secondary" - className="items-center" - onClick={() => fetchNow({ feedId: feed.id })} - > - <ArrowDownToLine className="mr-2 size-4" /> - {t("actions.fetch_now")} - </ActionButton> + <Tooltip> + <TooltipTrigger asChild> + <ActionButton + loading={isFetching} + variant="ghost" + className="items-center" + onClick={() => fetchNow({ feedId: feed.id })} + > + <ArrowDownToLine className="size-4" /> + </ActionButton> + </TooltipTrigger> + <TooltipContent>{t("actions.fetch_now")}</TooltipContent> + </Tooltip> <ActionConfirmingDialog title={`Delete Feed "${feed.name}"?`} description={`Are you sure you want to delete the feed "${feed.name}"?`} @@ -364,8 +401,7 @@ export function FeedRow({ feed }: { feed: ZFeed }) { )} > <Button variant="destructive" disabled={isDeleting}> - <Trash2 className="mr-2 size-4" /> - {t("actions.delete")} + <Trash2 className="size-4" /> </Button> </ActionConfirmingDialog> </TableCell> diff --git a/apps/web/components/settings/ImportExport.tsx b/apps/web/components/settings/ImportExport.tsx index 43b934a6..35c2b88f 100644 --- a/apps/web/components/settings/ImportExport.tsx +++ b/apps/web/components/settings/ImportExport.tsx @@ -6,6 +6,13 @@ import { useRouter } from "next/navigation"; import { buttonVariants } from "@/components/ui/button"; import FilePickerButton from "@/components/ui/file-picker-button"; import { Progress } from "@/components/ui/progress"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; import { @@ -20,7 +27,6 @@ import { } from "@/lib/importBookmarkParser"; import { cn } from "@/lib/utils"; import { useMutation } from "@tanstack/react-query"; -import { TRPCClientError } from "@trpc/client"; import { Download, Upload } from "lucide-react"; import { @@ -63,6 +69,8 @@ function ImportCard({ function ExportButton() { const { t } = useTranslation(); + const [format, setFormat] = useState<"json" | "netscape">("json"); + return ( <Card className="transition-all hover:shadow-md"> <CardContent className="flex items-center gap-3 p-4"> @@ -72,9 +80,21 @@ function ExportButton() { <div className="flex-1"> <h3 className="font-medium">Export File</h3> <p>{t("settings.import.export_links_and_notes")}</p> + <Select + value={format} + onValueChange={(value) => setFormat(value as "json" | "netscape")} + > + <SelectTrigger className="mt-2 w-[180px]"> + <SelectValue placeholder="Format" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="json">JSON (Karakeep format)</SelectItem> + <SelectItem value="netscape">HTML (Netscape format)</SelectItem> + </SelectContent> + </Select> </div> <Link - href="/api/bookmarks/export" + href={`/api/bookmarks/export?format=${format}`} className={cn( buttonVariants({ variant: "default", size: "sm" }), "flex items-center gap-2", @@ -104,7 +124,7 @@ export function ImportExportRow() { const { mutateAsync: parseAndCreateBookmark } = useMutation({ mutationFn: async (toImport: { bookmark: ParsedBookmark; - listId: string; + listIds: string[]; }) => { const bookmark = toImport.bookmark; if (bookmark.content === undefined) { @@ -116,6 +136,7 @@ export function ImportExportRow() { ? new Date(bookmark.addDate * 1000) : undefined, note: bookmark.notes, + archived: bookmark.archived, ...(bookmark.content.type === BookmarkTypes.LINK ? { type: BookmarkTypes.LINK, @@ -129,20 +150,14 @@ export function ImportExportRow() { await Promise.all([ // Add to import list - addToList({ - bookmarkId: created.id, - listId: toImport.listId, - }).catch((e) => { - if ( - e instanceof TRPCClientError && - e.message.includes("already in the list") - ) { - /* empty */ - } else { - throw e; - } - }), - + ...[ + toImport.listIds.map((listId) => + addToList({ + bookmarkId: created.id, + listId, + }), + ), + ], // Update tags bookmark.tags.length > 0 ? updateTags({ @@ -192,7 +207,7 @@ export function ImportExportRow() { return; } - const importList = await createList({ + const rootList = await createList({ name: t("settings.import.imported_bookmarks"), icon: "⬆️", }); @@ -201,33 +216,83 @@ export function ImportExportRow() { setImportProgress({ done: 0, total: finalBookmarksToImport.length }); + // Precreate folder lists + const allRequiredPaths = new Set<string>(); + // collect the paths of all bookmarks that have non-empty paths + for (const bookmark of finalBookmarksToImport) { + for (const path of bookmark.paths) { + if (path && path.length > 0) { + // We need every prefix of the path for the hierarchy + for (let i = 1; i <= path.length; i++) { + const subPath = path.slice(0, i); + const pathKey = subPath.join("/"); + allRequiredPaths.add(pathKey); + } + } + } + } + + // Convert to array and sort by depth (so that parent paths come first) + const allRequiredPathsArray = Array.from(allRequiredPaths).sort( + (a, b) => a.split("/").length - b.split("/").length, + ); + + const pathMap: Record<string, string> = {}; + + // Root list is the parent for top-level folders + // Represent root as empty string + pathMap[""] = rootList.id; + + for (const pathKey of allRequiredPathsArray) { + const parts = pathKey.split("/"); + const parentKey = parts.slice(0, -1).join("/"); + const parentId = pathMap[parentKey] || rootList.id; + + const folderName = parts[parts.length - 1]; + // Create the list + const folderList = await createList({ + name: folderName, + parentId: parentId, + icon: "📁", + }); + pathMap[pathKey] = folderList.id; + } + const importPromises = finalBookmarksToImport.map( - (bookmark) => () => - parseAndCreateBookmark({ - bookmark: bookmark, - listId: importList.id, - }).then( - (value) => { - setImportProgress((prev) => { - const newDone = (prev?.done ?? 0) + 1; - return { - done: newDone, - total: finalBookmarksToImport.length, - }; - }); - return { status: "fulfilled" as const, value }; - }, - () => { - setImportProgress((prev) => { - const newDone = (prev?.done ?? 0) + 1; - return { - done: newDone, - total: finalBookmarksToImport.length, - }; - }); - return { status: "rejected" as const }; - }, - ), + (bookmark) => async () => { + // Determine the target list ids + const listIds = bookmark.paths.map( + (path) => pathMap[path.join("/")] || rootList.id, + ); + if (listIds.length === 0) { + listIds.push(rootList.id); + } + + try { + const created = await parseAndCreateBookmark({ + bookmark: bookmark, + listIds, + }); + + setImportProgress((prev) => { + const newDone = (prev?.done ?? 0) + 1; + return { + done: newDone, + total: finalBookmarksToImport.length, + }; + }); + return { status: "fulfilled" as const, value: created }; + } catch (e) { + setImportProgress((prev) => { + const newDone = (prev?.done ?? 0) + 1; + return { + done: newDone, + total: finalBookmarksToImport.length, + }; + }); + return { status: "rejected" as const }; + } + }, ); const CONCURRENCY_LIMIT = 20; @@ -268,7 +333,7 @@ export function ImportExportRow() { }); } - router.push(`/dashboard/lists/${importList.id}`); + router.push(`/dashboard/lists/${rootList.id}`); }, onError: (error) => { setImportProgress(null); // Clear progress on initial parsing error diff --git a/apps/web/components/settings/UserOptions.tsx b/apps/web/components/settings/UserOptions.tsx index 33ffc46a..3918ceed 100644 --- a/apps/web/components/settings/UserOptions.tsx +++ b/apps/web/components/settings/UserOptions.tsx @@ -1,11 +1,23 @@ "use client"; +import { useEffect } from "react"; +import { useClientConfig } from "@/lib/clientConfig"; import { useTranslation } from "@/lib/i18n/client"; import { useInterfaceLang } from "@/lib/userLocalSettings/bookmarksLayout"; import { updateInterfaceLang } from "@/lib/userLocalSettings/userLocalSettings"; +import { useUserSettings } from "@/lib/userSettings"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { useUpdateUserSettings } from "@karakeep/shared-react/hooks/users"; import { langNameMappings } from "@karakeep/shared/langs"; +import { + ZUserSettings, + zUserSettingsSchema, +} from "@karakeep/shared/types/users"; +import { Form, FormField } from "../ui/form"; import { Label } from "../ui/label"; import { Select, @@ -14,6 +26,7 @@ import { SelectTrigger, SelectValue, } from "../ui/select"; +import { toast } from "../ui/use-toast"; const LanguageSelect = () => { const lang = useInterfaceLang(); @@ -38,6 +51,132 @@ const LanguageSelect = () => { ); }; +export default function UserSettings() { + const { t } = useTranslation(); + const clientConfig = useClientConfig(); + const data = useUserSettings(); + const { mutate } = useUpdateUserSettings({ + onSuccess: () => { + toast({ + description: t("settings.info.user_settings.user_settings_updated"), + }); + }, + onError: () => { + toast({ + description: t("common.something_went_wrong"), + variant: "destructive", + }); + }, + }); + + const bookmarkClickActionTranslation: Record< + ZUserSettings["bookmarkClickAction"], + string + > = { + open_original_link: t( + "settings.info.user_settings.bookmark_click_action.open_external_url", + ), + expand_bookmark_preview: t( + "settings.info.user_settings.bookmark_click_action.open_bookmark_details", + ), + }; + + const archiveDisplayBehaviourTranslation: Record< + ZUserSettings["archiveDisplayBehaviour"], + string + > = { + show: t("settings.info.user_settings.archive_display_behaviour.show"), + hide: t("settings.info.user_settings.archive_display_behaviour.hide"), + }; + + const form = useForm<z.infer<typeof zUserSettingsSchema>>({ + resolver: zodResolver(zUserSettingsSchema), + defaultValues: data, + }); + + // When the actual user setting is loaded, reset the form to the current value + useEffect(() => { + form.reset(data); + }, [data]); + + return ( + <Form {...form}> + <FormField + control={form.control} + name="bookmarkClickAction" + render={({ field }) => ( + <div className="flex w-full flex-col gap-2"> + <Label> + {t("settings.info.user_settings.bookmark_click_action.title")} + </Label> + <Select + disabled={!!clientConfig.demoMode} + value={field.value} + onValueChange={(value) => { + mutate({ + bookmarkClickAction: + value as ZUserSettings["bookmarkClickAction"], + }); + }} + > + <SelectTrigger> + <SelectValue> + {bookmarkClickActionTranslation[field.value]} + </SelectValue> + </SelectTrigger> + <SelectContent> + {Object.entries(bookmarkClickActionTranslation).map( + ([value, label]) => ( + <SelectItem key={value} value={value}> + {label} + </SelectItem> + ), + )} + </SelectContent> + </Select> + </div> + )} + /> + <FormField + control={form.control} + name="archiveDisplayBehaviour" + render={({ field }) => ( + <div className="flex w-full flex-col gap-2"> + <Label> + {t("settings.info.user_settings.archive_display_behaviour.title")} + </Label> + <Select + disabled={!!clientConfig.demoMode} + value={field.value} + onValueChange={(value) => { + mutate({ + archiveDisplayBehaviour: + value as ZUserSettings["archiveDisplayBehaviour"], + }); + }} + > + <SelectTrigger> + <SelectValue> + {archiveDisplayBehaviourTranslation[field.value]} + </SelectValue> + </SelectTrigger> + <SelectContent> + {Object.entries(archiveDisplayBehaviourTranslation).map( + ([value, label]) => ( + <SelectItem key={value} value={value}> + {label} + </SelectItem> + ), + )} + </SelectContent> + </Select> + </div> + )} + /> + </Form> + ); +} + export function UserOptions() { const { t } = useTranslation(); @@ -46,9 +185,12 @@ export function UserOptions() { <div className="mb-4 w-full text-lg font-medium sm:w-1/3"> {t("settings.info.options")} </div> - <div className="flex w-full flex-col gap-2"> - <Label>{t("settings.info.interface_lang")}</Label> - <LanguageSelect /> + <div className="flex w-full flex-col gap-3"> + <div className="flex w-full flex-col gap-2"> + <Label>{t("settings.info.interface_lang")}</Label> + <LanguageSelect /> + </div> + <UserSettings /> </div> </div> ); diff --git a/apps/web/components/ui/copy-button.tsx b/apps/web/components/ui/copy-button.tsx index a51ce902..1cb405da 100644 --- a/apps/web/components/ui/copy-button.tsx +++ b/apps/web/components/ui/copy-button.tsx @@ -1,6 +1,10 @@ -import React, { useEffect } from "react";
+import React, { useEffect, useState } from "react";
+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,
getStringToCopy,
@@ -35,3 +39,38 @@ export default function CopyBtn({ </button>
);
}
+
+export function CopyBtnV2({
+ className,
+ getStringToCopy,
+}: {
+ className?: string;
+ getStringToCopy: () => string;
+}) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async (url: string) => {
+ try {
+ await navigator.clipboard.writeText(url);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ toast({
+ description:
+ "Failed to copy link. Browsers only support copying to the clipboard from https pages.",
+ variant: "destructive",
+ });
+ }
+ };
+
+ return (
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => handleCopy(getStringToCopy())}
+ className={cn("shrink-0", className)}
+ >
+ {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
+ </Button>
+ );
+}
diff --git a/apps/web/components/ui/input.tsx b/apps/web/components/ui/input.tsx index 09f9def9..66cd1108 100644 --- a/apps/web/components/ui/input.tsx +++ b/apps/web/components/ui/input.tsx @@ -1,23 +1,19 @@ import * as React from "react"; import { cn } from "@/lib/utils"; -import { LucideIcon } from "lucide-react"; export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { - startIcon?: LucideIcon; - endIcon?: LucideIcon; + startIcon?: React.ReactNode; + endIcon?: React.ReactNode; } const Input = React.forwardRef<HTMLInputElement, InputProps>( ({ className, type, startIcon, endIcon, ...props }, ref) => { - const StartIcon = startIcon; - const EndIcon = endIcon; - return ( <div className="relative w-full"> - {StartIcon && ( + {startIcon && ( <div className="absolute left-2 top-1/2 -translate-y-1/2 transform"> - <StartIcon size={18} className="text-muted-foreground" /> + {startIcon} </div> )} <input @@ -31,9 +27,9 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>( ref={ref} {...props} /> - {EndIcon && ( + {endIcon && ( <div className="absolute right-3 top-1/2 -translate-y-1/2 transform"> - <EndIcon className="text-muted-foreground" size={18} /> + {endIcon} </div> )} </div> diff --git a/apps/web/components/utils/useShowArchived.tsx b/apps/web/components/utils/useShowArchived.tsx new file mode 100644 index 00000000..3fc66e91 --- /dev/null +++ b/apps/web/components/utils/useShowArchived.tsx @@ -0,0 +1,24 @@ +import { useCallback } from "react"; +import { useUserSettings } from "@/lib/userSettings"; +import { parseAsBoolean, useQueryState } from "nuqs"; + +export function useShowArchived() { + const userSettings = useUserSettings(); + const [showArchived, setShowArchived] = useQueryState( + "includeArchived", + parseAsBoolean + .withOptions({ + shallow: false, + }) + .withDefault(userSettings.archiveDisplayBehaviour === "show"), + ); + + const onClickShowArchived = useCallback(() => { + setShowArchived((prev) => !prev); + }, [setShowArchived]); + + return { + showArchived, + onClickShowArchived, + }; +} |
