feat: favorites (#2103)

* feat: favorites and templates(ee)

* rename migrations

* fix sidebar

* cleanup tabs

* fix

* turn off templates

* cleanup

* uuid validation
This commit is contained in:
Philip Okugbe
2026-04-12 22:06:25 +01:00
committed by GitHub
parent 57efb91bd3
commit d42091ccb1
90 changed files with 4557 additions and 187 deletions
@@ -12,6 +12,8 @@ import {
IconMarkdown,
IconMessage,
IconPrinter,
IconStar,
IconStarFilled,
IconTrash,
IconWifiOff,
} from "@tabler/icons-react";
@@ -42,6 +44,11 @@ import { PageStateSegmentedControl } from "@/features/user/components/page-state
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { PageShareModal } from "@/ee/page-permission";
import {
useFavoriteIds,
useAddFavoriteMutation,
useRemoveFavoriteMutation,
} from "@/features/favorite/queries/favorite-query";
import {
useWatchStatusQuery,
useWatchPageMutation,
@@ -130,6 +137,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
] = useDisclosure(false);
const [pageEditor] = useAtom(pageEditorAtom);
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
const favoriteIds = useFavoriteIds("page");
const addFavoriteMutation = useAddFavoriteMutation();
const removeFavoriteMutation = useRemoveFavoriteMutation();
const isFavorited = page?.id ? favoriteIds.has(page.id) : false;
const { data: watchStatus } = useWatchStatusQuery(page?.id);
const watchPage = useWatchPageMutation();
const unwatchPage = useUnwatchPageMutation();
@@ -165,6 +176,16 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
openDeleteModal({ onConfirm: () => tree?.delete(page.id) });
};
const handleToggleFavorite = () => {
if (!page?.id) return;
const params = { type: "page" as const, pageId: page.id };
if (isFavorited) {
removeFavoriteMutation.mutate(params);
} else {
addFavoriteMutation.mutate(params);
}
};
return (
<>
<Menu
@@ -196,6 +217,19 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
{t("Copy as Markdown")}
</Menu.Item>
<Menu.Item
leftSection={
isFavorited ? (
<IconStarFilled size={16} color="var(--mantine-color-yellow-5)" />
) : (
<IconStar size={16} />
)
}
onClick={handleToggleFavorite}
>
{isFavorited ? t("Remove from favorites") : t("Add to favorites")}
</Menu.Item>
{watchStatus?.watching ? (
<Menu.Item
leftSection={<IconEyeOff size={16} />}
@@ -17,6 +17,7 @@ import {
movePage,
getPageBreadcrumbs,
getRecentChanges,
getCreatedByPages,
getAllSidebarPages,
getDeletedPages,
restorePage,
@@ -244,7 +245,7 @@ export function useGetSidebarPagesQuery(
return useInfiniteQuery({
queryKey: ["sidebar-pages", data],
enabled: !!data?.pageId || !!data?.spaceId,
queryFn: ({ pageParam }) => getSidebarPages({ ...data, cursor: pageParam }),
queryFn: ({ pageParam }) => getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
initialPageParam: undefined,
getNextPageParam: (lastPage) =>
lastPage.meta?.nextCursor ?? undefined,
@@ -255,7 +256,7 @@ export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
return useInfiniteQuery({
queryKey: ["root-sidebar-pages", data.spaceId],
queryFn: async ({ pageParam }) => {
return getSidebarPages({ spaceId: data.spaceId, cursor: pageParam });
return getSidebarPages({ spaceId: data.spaceId, cursor: pageParam, limit: 100 });
},
initialPageParam: undefined,
getNextPageParam: (lastPage) =>
@@ -285,12 +286,26 @@ export async function fetchAllAncestorChildren(params: SidebarPagesParams) {
return buildTree(allItems);
}
export function useRecentChangesQuery(
spaceId?: string,
): UseQueryResult<IPagination<IPage>, Error> {
return useQuery({
export function useRecentChangesQuery(spaceId?: string) {
return useInfiniteQuery({
queryKey: ["recent-changes", spaceId],
queryFn: () => getRecentChanges(spaceId),
queryFn: ({ pageParam }) =>
getRecentChanges({ spaceId, cursor: pageParam, limit: 15 }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
refetchOnMount: true,
});
}
export function useCreatedByQuery(params?: { userId?: string; spaceId?: string }) {
const { userId, spaceId } = params ?? {};
return useInfiniteQuery({
queryKey: ["pages-created-by-user", { userId, spaceId }],
queryFn: ({ pageParam }) => getCreatedByPages({ userId, spaceId, cursor: pageParam, limit: 15 }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
refetchOnMount: true,
});
}
@@ -77,7 +77,7 @@ export async function getAllSidebarPages(
const pageParams: (string | undefined)[] = [];
do {
const req = await api.post("/pages/sidebar-pages", { ...params, cursor });
const req = await api.post("/pages/sidebar-pages", { ...params, cursor, limit: 100 });
const data: IPagination<IPage> = req.data;
pages.push(data);
@@ -100,9 +100,16 @@ export async function getPageBreadcrumbs(
}
export async function getRecentChanges(
spaceId?: string,
params?: QueryParams & { spaceId?: string },
): Promise<IPagination<IPage>> {
const req = await api.post("/pages/recent", { spaceId });
const req = await api.post("/pages/recent", params);
return req.data;
}
export async function getCreatedByPages(
params?: QueryParams & { userId?: string; spaceId?: string },
): Promise<IPagination<IPage>> {
const req = await api.post("/pages/created-by-user", params);
return req.data;
}
@@ -28,6 +28,8 @@ import {
IconLink,
IconPlus,
IconPointFilled,
IconStar,
IconStarFilled,
IconTrash,
} from "@tabler/icons-react";
import {
@@ -69,6 +71,7 @@ import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sideb
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import CopyPageModal from "../../components/copy-page-modal.tsx";
import { duplicatePage } from "../../services/page-service.ts";
import { useFavoriteIds, useAddFavoriteMutation, useRemoveFavoriteMutation } from "@/features/favorite/queries/favorite-query";
interface SpaceTreeProps {
spaceId: string;
@@ -506,6 +509,10 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
copyPageModalOpened,
{ open: openCopyPageModal, close: closeCopySpaceModal },
] = useDisclosure(false);
const favoriteIds = useFavoriteIds("page");
const addFavorite = useAddFavoriteMutation();
const removeFavorite = useRemoveFavoriteMutation();
const isFavorited = favoriteIds.has(node.data.id);
const handleCopyLink = () => {
const pageUrl =
@@ -608,6 +615,21 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
{t("Copy link")}
</Menu.Item>
<Menu.Item
leftSection={isFavorited ? <IconStarFilled size={16} /> : <IconStar size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (isFavorited) {
removeFavorite.mutate({ type: "page", pageId: node.data.id });
} else {
addFavorite.mutate({ type: "page", pageId: node.data.id });
}
}}
>
{isFavorited ? t("Remove from favorites") : t("Add to favorites")}
</Menu.Item>
<Menu.Item
leftSection={<IconFileExport size={16} />}
onClick={(e) => {
@@ -68,6 +68,7 @@ export interface SidebarPagesParams {
spaceId?: string;
pageId?: string;
cursor?: string;
limit?: number;
}
export interface IPageInput {