diff options
Diffstat (limited to 'apps/web/components/dashboard')
25 files changed, 497 insertions, 114 deletions
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> |
