aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/app/public/layout.tsx16
-rw-r--r--apps/web/app/public/lists/[listId]/not-found.tsx18
-rw-r--r--apps/web/app/public/lists/[listId]/page.tsx84
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx8
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx13
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx8
-rw-r--r--apps/web/components/dashboard/lists/PublicListLink.tsx67
-rw-r--r--apps/web/components/dashboard/lists/RssLink.tsx107
-rw-r--r--apps/web/components/dashboard/lists/ShareListModal.tsx4
-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/ui/copy-button.tsx41
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json11
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&apos;re looking for doesn&apos;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",