aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components/dashboard
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/components/dashboard')
-rw-r--r--apps/web/components/dashboard/SortOrderToggle.tsx36
-rw-r--r--apps/web/components/dashboard/bookmarks/AssetCard.tsx4
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkCard.tsx2
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx8
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx15
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx8
-rw-r--r--apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx2
-rw-r--r--apps/web/components/dashboard/bookmarks/LinkCard.tsx28
-rw-r--r--apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx4
-rw-r--r--apps/web/components/dashboard/bookmarks/TextCard.tsx4
-rw-r--r--apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx6
-rw-r--r--apps/web/components/dashboard/lists/EditListModal.tsx14
-rw-r--r--apps/web/components/dashboard/lists/ListOptions.tsx34
-rw-r--r--apps/web/components/dashboard/lists/PublicListLink.tsx67
-rw-r--r--apps/web/components/dashboard/lists/RssLink.tsx95
-rw-r--r--apps/web/components/dashboard/lists/ShareListModal.tsx70
-rw-r--r--apps/web/components/dashboard/preview/AssetContentSection.tsx2
-rw-r--r--apps/web/components/dashboard/preview/AttachmentBox.tsx2
-rw-r--r--apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx23
-rw-r--r--apps/web/components/dashboard/preview/BookmarkPreview.tsx120
-rw-r--r--apps/web/components/dashboard/preview/TextContentSection.tsx2
-rw-r--r--apps/web/components/dashboard/search/SearchInput.tsx2
-rw-r--r--apps/web/components/dashboard/sidebar/AllLists.tsx48
-rw-r--r--apps/web/components/dashboard/tags/TagOptions.tsx14
-rw-r--r--apps/web/components/dashboard/tags/TagPill.tsx1
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>