diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-06-01 20:46:41 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-06-01 20:46:41 +0100 |
| commit | ea1d0023bfee55358ebb1a96f3d06e783a219c0d (patch) | |
| tree | 5bddd451728cb7dd377574a9ea1ea591bca069c4 /apps | |
| parent | 3afe1e21df6dcc0483e74e0db02d9d82af32ecea (diff) | |
| download | karakeep-ea1d0023bfee55358ebb1a96f3d06e783a219c0d.tar.zst | |
feat: Add support for public lists (#1511)
* WIP: public lists
* Drop viewing modes
* Add the public endpoint for assets
* regen the openapi spec
* proper handling for different asset types
* Add num bookmarks and a no bookmark banner
* Correctly set page title
* Add a not-found page
* merge the RSS and public list endpoints
* Add e2e tests for the public endpoints
* Redesign the share list modal
* Make NEXTAUTH_SECRET not required
* propery render text bookmarks
* rebase migration
* fix public token tests
* Add more tests
Diffstat (limited to 'apps')
13 files changed, 563 insertions, 78 deletions
diff --git a/apps/web/app/public/layout.tsx b/apps/web/app/public/layout.tsx new file mode 100644 index 00000000..4203c44c --- /dev/null +++ b/apps/web/app/public/layout.tsx @@ -0,0 +1,16 @@ +import KarakeepLogo from "@/components/KarakeepIcon"; + +export default function PublicLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <div className="h-screen flex-col overflow-y-auto bg-muted"> + <header className="sticky left-0 right-0 top-0 z-50 flex h-16 items-center justify-between overflow-x-auto overflow-y-hidden bg-background p-4 shadow"> + <KarakeepLogo height={38} /> + </header> + <main className="container mx-3 mt-3 flex-1">{children}</main> + </div> + ); +} diff --git a/apps/web/app/public/lists/[listId]/not-found.tsx b/apps/web/app/public/lists/[listId]/not-found.tsx new file mode 100644 index 00000000..a6fd71dc --- /dev/null +++ b/apps/web/app/public/lists/[listId]/not-found.tsx @@ -0,0 +1,18 @@ +import { X } from "lucide-react"; + +export default function PublicListPageNotFound() { + return ( + <div className="mx-auto flex max-w-md flex-1 flex-col items-center justify-center px-4 py-16 text-center"> + <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-slate-100 dark:bg-slate-700"> + <X className="h-12 w-12 text-gray-300" strokeWidth={1.5} /> + </div> + <h1 className="mb-3 text-2xl font-semibold text-gray-800"> + List not found + </h1> + <p className="text-center text-gray-500"> + The list you're looking for doesn't exist or may have been + removed. + </p> + </div> + ); +} diff --git a/apps/web/app/public/lists/[listId]/page.tsx b/apps/web/app/public/lists/[listId]/page.tsx new file mode 100644 index 00000000..c0495b9f --- /dev/null +++ b/apps/web/app/public/lists/[listId]/page.tsx @@ -0,0 +1,84 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import NoBookmarksBanner from "@/components/dashboard/bookmarks/NoBookmarksBanner"; +import PublicBookmarkGrid from "@/components/public/lists/PublicBookmarkGrid"; +import PublicListHeader from "@/components/public/lists/PublicListHeader"; +import { Separator } from "@/components/ui/separator"; +import { api } from "@/server/api/client"; +import { TRPCError } from "@trpc/server"; + +export async function generateMetadata({ + params, +}: { + params: { listId: string }; +}): Promise<Metadata> { + // TODO: Don't load the entire list, just create an endpoint to get the list name + try { + const resp = await api.publicBookmarks.getPublicBookmarksInList({ + listId: params.listId, + }); + return { + title: `${resp.list.name} - Karakeep`, + }; + } catch (e) { + if (e instanceof TRPCError && e.code === "NOT_FOUND") { + notFound(); + } + } + return { + title: "Karakeep", + }; +} + +export default async function PublicListPage({ + params, +}: { + params: { listId: string }; +}) { + try { + const { list, bookmarks, nextCursor } = + await api.publicBookmarks.getPublicBookmarksInList({ + listId: params.listId, + }); + return ( + <div className="flex flex-col gap-3"> + <div className="flex items-center gap-2"> + <span className="text-2xl"> + {list.icon} {list.name} + {list.description && ( + <span className="mx-2 text-lg text-gray-400"> + {`(${list.description})`} + </span> + )} + </span> + </div> + <Separator /> + <PublicListHeader + list={{ + id: params.listId, + numItems: list.numItems, + }} + /> + {list.numItems > 0 ? ( + <PublicBookmarkGrid + list={{ + id: params.listId, + name: list.name, + description: list.description, + icon: list.icon, + numItems: list.numItems, + }} + bookmarks={bookmarks} + nextCursor={nextCursor} + /> + ) : ( + <NoBookmarksBanner /> + )} + </div> + ); + } catch (e) { + if (e instanceof TRPCError && e.code === "NOT_FOUND") { + notFound(); + } + } +} 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 6f55ca00..4b511a3c 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx @@ -8,7 +8,6 @@ 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"; @@ -17,6 +16,7 @@ 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/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 index 152a3fe4..1be48681 100644 --- a/apps/web/components/dashboard/lists/RssLink.tsx +++ b/apps/web/components/dashboard/lists/RssLink.tsx @@ -1,14 +1,14 @@ "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 { 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 { cn } from "@/lib/utils"; -import { Loader2, RotateCcw, Rss, Trash2 } from "lucide-react"; +import { Loader2, RotateCcw } from "lucide-react"; import { useTranslation } from "react-i18next"; export default function RssLink({ listId }: { listId: string }) { @@ -38,77 +38,58 @@ export default function RssLink({ listId }: { listId: string }) { return `${clientConfig.publicApiUrl}/v1/rss/lists/${listId}?token=${rssToken.token}`; }, [rssToken]); - const isLoading = isRegenPending || isClearPending || isTokenLoading; + const rssEnabled = rssUrl !== null; 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> + <> + {/* 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> - ) : ( - <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", - )} - /> + <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={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" + disabled={isRegenPending} > - {isLoading ? ( - <Loader2 className="h-3 w-3 animate-spin" /> + {isRegenPending ? ( + <Loader2 className="h-4 w-4 animate-spin" /> ) : ( - <Trash2 className="h-3 w-3" /> + <RotateCcw className="h-4 w-4" /> )} </Button> </div> </div> )} - </div> + </> ); } diff --git a/apps/web/components/dashboard/lists/ShareListModal.tsx b/apps/web/components/dashboard/lists/ShareListModal.tsx index 5c7b060e..16668e67 100644 --- a/apps/web/components/dashboard/lists/ShareListModal.tsx +++ b/apps/web/components/dashboard/lists/ShareListModal.tsx @@ -14,6 +14,7 @@ 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({ @@ -52,7 +53,8 @@ export function ShareListModal({ <DialogHeader> <DialogTitle>{t("lists.share_list")}</DialogTitle> </DialogHeader> - <DialogDescription className="mt-4 flex flex-col gap-2"> + <DialogDescription className="mt-4 space-y-6"> + <PublicListLink list={list} /> <RssLink listId={list.id} /> </DialogDescription> <DialogFooter className="sm:justify-end"> 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/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/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 39be43f3..3ad4a25e 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -304,7 +304,16 @@ "search_query": "Search Query", "search_query_help": "Learn more about the search query language.", "description": "Description (Optional)", - "generate_rss_feed": "Generate RSS Feed" + "rss": { + "title": "RSS Feed", + "description": "Enable an RSS feed for this list", + "feed_url": "RSS Feed URL" + }, + "public_list": { + "title": "Public List", + "description": "Allow others to view this list", + "share_link": "Share Link" + } }, "tags": { "all_tags": "All Tags", |
