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 | |
| 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')
| -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 | ||||
| -rw-r--r-- | apps/web/lib/clientConfig.tsx | 2 | ||||
| -rw-r--r-- | apps/web/lib/i18n/locales/en/translation.json | 4 | ||||
| -rw-r--r-- | apps/workers/trpc.ts | 34 |
6 files changed, 205 insertions, 33 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> + ); +} diff --git a/apps/web/lib/clientConfig.tsx b/apps/web/lib/clientConfig.tsx index 2a66de37..03089e49 100644 --- a/apps/web/lib/clientConfig.tsx +++ b/apps/web/lib/clientConfig.tsx @@ -3,6 +3,8 @@ import { createContext, useContext } from "react"; import type { ClientConfig } from "@karakeep/shared/config"; export const ClientConfigCtx = createContext<ClientConfig>({ + publicUrl: "", + publicApiUrl: "", demoMode: undefined, auth: { disableSignups: false, diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 48d32f37..d7ee54a3 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -282,6 +282,7 @@ "favourites": "Favourites", "new_list": "New List", "edit_list": "Edit List", + "share_list": "Share List", "new_nested_list": "New Nested List", "merge_list": "Merge List", "destination_list": "Destination List", @@ -294,7 +295,8 @@ "smart_list": "Smart List", "search_query": "Search Query", "search_query_help": "Learn more about the search query language.", - "description": "Description (Optional)" + "description": "Description (Optional)", + "generate_rss_feed": "Generate RSS Feed" }, "tags": { "all_tags": "All Tags", diff --git a/apps/workers/trpc.ts b/apps/workers/trpc.ts index c5f880ad..28cd2d0b 100644 --- a/apps/workers/trpc.ts +++ b/apps/workers/trpc.ts @@ -1,36 +1,8 @@ -import { eq } from "drizzle-orm"; - -import { db } from "@karakeep/db"; -import { users } from "@karakeep/db/schema"; -import { AuthedContext, createCallerFactory } from "@karakeep/trpc"; +import { createCallerFactory } from "@karakeep/trpc"; +import { buildImpersonatingAuthedContext as buildAuthedContext } from "@karakeep/trpc/lib/impersonate"; import { appRouter } from "@karakeep/trpc/routers/_app"; -/** - * This is only safe to use in the context of a worker. - */ -export async function buildImpersonatingAuthedContext( - userId: string, -): Promise<AuthedContext> { - const user = await db.query.users.findFirst({ - where: eq(users.id, userId), - }); - if (!user) { - throw new Error("User not found"); - } - - return { - user: { - id: user.id, - name: user.name, - email: user.email, - role: user.role, - }, - db, - req: { - ip: null, - }, - }; -} +export const buildImpersonatingAuthedContext = buildAuthedContext; /** * This is only safe to use in the context of a worker. |
