diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-05-31 18:46:04 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-05-31 18:46:04 +0100 |
| commit | 9695bba2e993b48ae333da622fa459dbaacb9349 (patch) | |
| tree | c6bffbcdd73151671343f27012e82bea5a05ab6b /apps/web/components/dashboard/lists | |
| parent | b218118b84291de4a9c1cd400dc58afab7054b78 (diff) | |
| download | karakeep-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.tsx | 16 | ||||
| -rw-r--r-- | apps/web/components/dashboard/lists/RssLink.tsx | 114 | ||||
| -rw-r--r-- | apps/web/components/dashboard/lists/ShareListModal.tsx | 68 |
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> + ); +} |
