aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components/dashboard
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/dashboard
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/dashboard')
-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
6 files changed, 131 insertions, 76 deletions
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">