aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/components')
-rw-r--r--apps/web/components/admin/BackgroundJobs.tsx24
-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
-rw-r--r--apps/web/components/public/lists/PublicBookmarkGrid.tsx247
-rw-r--r--apps/web/components/public/lists/PublicListHeader.tsx17
-rw-r--r--apps/web/components/settings/FeedSettings.tsx72
-rw-r--r--apps/web/components/settings/ImportExport.tsx155
-rw-r--r--apps/web/components/settings/UserOptions.tsx148
-rw-r--r--apps/web/components/ui/copy-button.tsx41
-rw-r--r--apps/web/components/ui/input.tsx16
-rw-r--r--apps/web/components/utils/useShowArchived.tsx24
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,
+ };
+}