aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components/dashboard/lists
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-05-31 18:46:04 +0100
committerGitHub <noreply@github.com>2025-05-31 18:46:04 +0100
commit9695bba2e993b48ae333da622fa459dbaacb9349 (patch)
treec6bffbcdd73151671343f27012e82bea5a05ab6b /apps/web/components/dashboard/lists
parentb218118b84291de4a9c1cd400dc58afab7054b78 (diff)
downloadkarakeep-9695bba2e993b48ae333da622fa459dbaacb9349.tar.zst
feat: Generate RSS feeds from lists (#1507)
* refactor: Move bookmark utils from shared-react to shared * Expose RSS feeds for lists * Add e2e tests * Slightly improve the look of the share dialog * allow specifying a limit in the rss endpoint
Diffstat (limited to 'apps/web/components/dashboard/lists')
-rw-r--r--apps/web/components/dashboard/lists/ListOptions.tsx16
-rw-r--r--apps/web/components/dashboard/lists/RssLink.tsx114
-rw-r--r--apps/web/components/dashboard/lists/ShareListModal.tsx68
3 files changed, 197 insertions, 1 deletions
diff --git a/apps/web/components/dashboard/lists/ListOptions.tsx b/apps/web/components/dashboard/lists/ListOptions.tsx
index 9a979686..d0dedfd5 100644
--- a/apps/web/components/dashboard/lists/ListOptions.tsx
+++ b/apps/web/components/dashboard/lists/ListOptions.tsx
@@ -6,13 +6,14 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTranslation } from "@/lib/i18n/client";
-import { FolderInput, Pencil, Plus, Trash2 } from "lucide-react";
+import { FolderInput, Pencil, Plus, Share, 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,
@@ -31,9 +32,15 @@ export function ListOptions({
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 +74,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" />
diff --git a/apps/web/components/dashboard/lists/RssLink.tsx b/apps/web/components/dashboard/lists/RssLink.tsx
new file mode 100644
index 00000000..152a3fe4
--- /dev/null
+++ b/apps/web/components/dashboard/lists/RssLink.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import { useMemo } from "react";
+import { Badge } from "@/components/ui/badge";
+import { Button, buttonVariants } from "@/components/ui/button";
+import CopyBtn from "@/components/ui/copy-button";
+import { Input } from "@/components/ui/input";
+import { useClientConfig } from "@/lib/clientConfig";
+import { api } from "@/lib/trpc";
+import { cn } from "@/lib/utils";
+import { Loader2, RotateCcw, Rss, Trash2 } 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 isLoading = isRegenPending || isClearPending || isTokenLoading;
+
+ return (
+ <div className="flex items-center gap-3 rounded-lg border bg-white p-3">
+ <Badge variant="outline" className="text-xs">
+ RSS
+ </Badge>
+ {!rssUrl ? (
+ <div className="flex items-center gap-2">
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => regenRssToken({ listId })}
+ disabled={isLoading}
+ >
+ {isLoading ? (
+ <Loader2 className="h-3 w-3 animate-spin" />
+ ) : (
+ <span className="flex items-center">
+ <Rss className="mr-2 h-4 w-4 flex-shrink-0 text-orange-500" />
+ {t("lists.generate_rss_feed")}
+ </span>
+ )}
+ </Button>
+ </div>
+ ) : (
+ <div className="flex min-w-0 flex-1 items-center gap-2">
+ <Input
+ value={rssUrl}
+ readOnly
+ className="h-8 min-w-0 flex-1 font-mono text-xs"
+ />
+ <div className="flex flex-shrink-0 gap-1">
+ <CopyBtn
+ getStringToCopy={() => {
+ return rssUrl;
+ }}
+ className={cn(
+ buttonVariants({ variant: "outline", size: "sm" }),
+ "h-8 w-8 p-0",
+ )}
+ />
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => regenRssToken({ listId })}
+ disabled={isLoading}
+ className="h-8 w-8 p-0"
+ >
+ {isLoading ? (
+ <Loader2 className="h-3 w-3 animate-spin" />
+ ) : (
+ <RotateCcw className="h-3 w-3" />
+ )}
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => clearRssToken({ listId })}
+ disabled={isLoading}
+ className="h-8 w-8 p-0 text-destructive hover:text-destructive"
+ >
+ {isLoading ? (
+ <Loader2 className="h-3 w-3 animate-spin" />
+ ) : (
+ <Trash2 className="h-3 w-3" />
+ )}
+ </Button>
+ </div>
+ </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..5c7b060e
--- /dev/null
+++ b/apps/web/components/dashboard/lists/ShareListModal.tsx
@@ -0,0 +1,68 @@
+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 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 flex flex-col gap-2">
+ <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>
+ );
+}