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
@@ -0,0 +1,76 @@
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconStar, IconStarFilled } from "@tabler/icons-react";
import {
useFavoriteIds,
useAddFavoriteMutation,
useRemoveFavoriteMutation,
} from "../queries/favorite-query";
import { FavoriteType } from "../types/favorite.types";
import { ToggleFavoriteParams } from "../services/favorite-service";
import { useTranslation } from "react-i18next";
type StarButtonProps = {
type: FavoriteType;
pageId?: string;
spaceId?: string;
templateId?: string;
size?: number;
};
function getEntityId(props: StarButtonProps): string | undefined {
if (props.type === "page") return props.pageId;
if (props.type === "space") return props.spaceId;
if (props.type === "template") return props.templateId;
return undefined;
}
export default function StarButton(props: StarButtonProps) {
const { type, size = 18 } = props;
const { t } = useTranslation();
const favoriteIds = useFavoriteIds(type);
const addMutation = useAddFavoriteMutation();
const removeMutation = useRemoveFavoriteMutation();
const entityId = getEntityId(props);
const isFavorited = entityId ? favoriteIds.has(entityId) : false;
const isPending = addMutation.isPending || removeMutation.isPending;
const handleToggle = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
const params: ToggleFavoriteParams = {
type,
pageId: props.pageId,
spaceId: props.spaceId,
templateId: props.templateId,
};
if (isFavorited) {
removeMutation.mutate(params);
} else {
addMutation.mutate(params);
}
};
return (
<Tooltip
label={isFavorited ? t("Remove from favorites") : t("Add to favorites")}
openDelay={250}
withArrow
>
<ActionIcon
variant="subtle"
color={isFavorited ? "yellow" : "gray"}
onClick={handleToggle}
loading={isPending}
>
{isFavorited ? (
<IconStarFilled size={size} />
) : (
<IconStar size={size} stroke={2} />
)}
</ActionIcon>
</Tooltip>
);
}
@@ -0,0 +1,78 @@
import {
useQuery,
useInfiniteQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import {
addFavorite,
removeFavorite,
getFavorites,
ToggleFavoriteParams,
} from "../services/favorite-service";
import { IPagination } from "@/lib/types.ts";
import { IFavorite, FavoriteType } from "../types/favorite.types";
export function useFavoritesQuery(type?: FavoriteType) {
return useInfiniteQuery({
queryKey: ["favorites", type],
queryFn: ({ pageParam }) =>
getFavorites({ type, cursor: pageParam, limit: 15 }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
refetchOnMount: true,
});
}
export function useFavoriteIds(type: FavoriteType): Set<string> {
const { data } = useQuery<IPagination<IFavorite>>({
queryKey: ["favorite-ids", type],
queryFn: () => getFavorites({ type, limit: 50 }),
refetchOnMount: true,
});
const ids = new Set<string>();
if (data?.items) {
for (const fav of data.items) {
let id: string | undefined;
if (type === "page") id = fav.pageId;
else if (type === "space") id = fav.spaceId;
else if (type === "template") id = fav.templateId;
if (id) ids.add(id);
}
}
return ids;
}
export function useAddFavoriteMutation() {
const queryClient = useQueryClient();
return useMutation<void, Error, ToggleFavoriteParams>({
mutationFn: (data) => addFavorite(data),
onSuccess: (_result, variables) => {
queryClient.invalidateQueries({
queryKey: ["favorite-ids", variables.type],
});
queryClient.invalidateQueries({
queryKey: ["favorites", variables.type],
});
},
});
}
export function useRemoveFavoriteMutation() {
const queryClient = useQueryClient();
return useMutation<void, Error, ToggleFavoriteParams>({
mutationFn: (data) => removeFavorite(data),
onSuccess: (_result, variables) => {
queryClient.invalidateQueries({
queryKey: ["favorite-ids", variables.type],
});
queryClient.invalidateQueries({
queryKey: ["favorites", variables.type],
});
},
});
}
@@ -0,0 +1,31 @@
import api from "@/lib/api-client";
import { IPagination } from "@/lib/types.ts";
import { IFavorite, FavoriteType } from "../types/favorite.types";
export type ToggleFavoriteParams = {
type: FavoriteType;
pageId?: string;
spaceId?: string;
templateId?: string;
};
export async function addFavorite(
params: ToggleFavoriteParams,
): Promise<void> {
await api.post("/favorites/add", params);
}
export async function removeFavorite(
params: ToggleFavoriteParams,
): Promise<void> {
await api.post("/favorites/remove", params);
}
export async function getFavorites(params?: {
type?: FavoriteType;
limit?: number;
cursor?: string;
}): Promise<IPagination<IFavorite>> {
const req = await api.post("/favorites", params);
return req.data;
}
@@ -0,0 +1,32 @@
export type FavoriteType = "page" | "space" | "template";
export type IFavorite = {
id: string;
userId: string;
pageId: string | null;
spaceId: string | null;
templateId: string | null;
type: FavoriteType;
workspaceId: string;
createdAt: string;
page?: {
id: string;
slugId: string;
title: string;
icon: string | null;
spaceId: string;
};
space?: {
id: string;
name: string;
slug: string;
logo: string | null;
};
template?: {
id: string;
title: string;
description: string | null;
icon: string | null;
spaceId: string | null;
};
};