aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components/public/lists
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-06-01 20:46:41 +0100
committerGitHub <noreply@github.com>2025-06-01 20:46:41 +0100
commitea1d0023bfee55358ebb1a96f3d06e783a219c0d (patch)
tree5bddd451728cb7dd377574a9ea1ea591bca069c4 /apps/web/components/public/lists
parent3afe1e21df6dcc0483e74e0db02d9d82af32ecea (diff)
downloadkarakeep-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/web/components/public/lists')
-rw-r--r--apps/web/components/public/lists/PublicBookmarkGrid.tsx247
-rw-r--r--apps/web/components/public/lists/PublicListHeader.tsx17
2 files changed, 264 insertions, 0 deletions
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>
+ );
+}