diff --git a/apps/client/src/features/favorite/queries/favorite-query.ts b/apps/client/src/features/favorite/queries/favorite-query.ts index cbd580c2..72d71166 100644 --- a/apps/client/src/features/favorite/queries/favorite-query.ts +++ b/apps/client/src/features/favorite/queries/favorite-query.ts @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { useQuery, useInfiniteQuery, @@ -8,10 +9,10 @@ import { addFavorite, removeFavorite, getFavorites, + getFavoriteIds, ToggleFavoriteParams, } from "../services/favorite-service"; -import { IPagination } from "@/lib/types.ts"; -import { IFavorite, FavoriteType } from "../types/favorite.types"; +import { FavoriteType } from "../types/favorite.types"; export function useFavoritesQuery(type?: FavoriteType) { return useInfiniteQuery({ @@ -26,23 +27,21 @@ export function useFavoritesQuery(type?: FavoriteType) { } export function useFavoriteIds(type: FavoriteType): Set { - const { data } = useQuery>({ + const { data } = useQuery({ queryKey: ["favorite-ids", type], - queryFn: () => getFavorites({ type, limit: 50 }), + queryFn: () => getFavoriteIds(type), refetchOnMount: true, }); - const ids = new Set(); - 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; + const items = data?.items; + return useMemo(() => new Set(items ?? []), [items]); +} + +function getEntityId(variables: ToggleFavoriteParams): string | undefined { + if (variables.type === "page") return variables.pageId; + if (variables.type === "space") return variables.spaceId; + if (variables.type === "template") return variables.templateId; + return undefined; } export function useAddFavoriteMutation() { @@ -51,9 +50,17 @@ export function useAddFavoriteMutation() { return useMutation({ mutationFn: (data) => addFavorite(data), onSuccess: (_result, variables) => { - queryClient.invalidateQueries({ - queryKey: ["favorite-ids", variables.type], - }); + const entityId = getEntityId(variables); + if (entityId) { + queryClient.setQueryData( + ["favorite-ids", variables.type], + (old: { items: string[]; meta: any } | undefined) => { + if (!old) return old; + if (old.items.includes(entityId)) return old; + return { ...old, items: [...old.items, entityId] }; + }, + ); + } queryClient.invalidateQueries({ queryKey: ["favorites", variables.type], }); @@ -67,9 +74,16 @@ export function useRemoveFavoriteMutation() { return useMutation({ mutationFn: (data) => removeFavorite(data), onSuccess: (_result, variables) => { - queryClient.invalidateQueries({ - queryKey: ["favorite-ids", variables.type], - }); + const entityId = getEntityId(variables); + if (entityId) { + queryClient.setQueryData( + ["favorite-ids", variables.type], + (old: { items: string[]; meta: any } | undefined) => { + if (!old) return old; + return { ...old, items: old.items.filter((id) => id !== entityId) }; + }, + ); + } queryClient.invalidateQueries({ queryKey: ["favorites", variables.type], }); diff --git a/apps/client/src/features/favorite/services/favorite-service.ts b/apps/client/src/features/favorite/services/favorite-service.ts index 73174c25..1fd2da30 100644 --- a/apps/client/src/features/favorite/services/favorite-service.ts +++ b/apps/client/src/features/favorite/services/favorite-service.ts @@ -21,6 +21,11 @@ export async function removeFavorite( await api.post("/favorites/remove", params); } +export async function getFavoriteIds(type: FavoriteType): Promise> { + const req = await api.post>("/favorites/ids", { type }); + return req.data; +} + export async function getFavorites(params?: { type?: FavoriteType; limit?: number; diff --git a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx index 7bb1c88e..0ad0094e 100644 --- a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx +++ b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx @@ -16,6 +16,8 @@ import { IconPlus, IconSearch, IconSettings, + IconStar, + IconStarFilled, IconTrash, } from "@tabler/icons-react"; import { @@ -43,6 +45,11 @@ import PageImportModal from "@/features/page/components/page-import-modal.tsx"; import { useTranslation } from "react-i18next"; import { SwitchSpace } from "./switch-space"; import ExportModal from "@/components/common/export-modal"; +import { + useFavoriteIds, + useAddFavoriteMutation, + useRemoveFavoriteMutation, +} from "@/features/favorite/queries/favorite-query"; import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import { searchSpotlight } from "@/features/search/constants"; @@ -56,7 +63,6 @@ export function SpaceSidebar() { const [mobileSidebarOpened] = useAtom(mobileSidebarAtom); const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); - const { spaceSlug } = useParams(); const { data: space } = useGetSpaceBySlugQuery(spaceSlug); @@ -82,7 +88,12 @@ export function SpaceSidebar() { marginBottom: 3, }} > - + { + const params = { type: "space" as const, spaceId }; + if (isFavorited) { + removeFavoriteMutation.mutate(params); + } else { + addFavoriteMutation.mutate(params); + } + }; + const handleToggleWatch = () => { if (isWatching) { unwatchMutation.mutate(spaceId); @@ -265,6 +290,22 @@ function SpaceMenu({ + + ) : ( + + ) + } + > + {isFavorited ? t("Remove from favorites") : t("Add to favorites")} + + ; size?: number }) { + const { t } = useTranslation(); + const watchMutation = useWatchSpaceMutation(); + const unwatchMutation = useUnwatchSpaceMutation(); + const isWatching = watchedIds.has(spaceId); + const isPending = watchMutation.isPending || unwatchMutation.isPending; + + const handleToggle = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + if (isWatching) { + unwatchMutation.mutate(spaceId); + } else { + watchMutation.mutate(spaceId); + } + }; + + return ( + + + {isWatching ? ( + + ) : ( + + )} + + + ); +} + interface AllSpacesListProps { spaces: any[]; onSearch: (query: string) => void; @@ -44,6 +89,7 @@ export default function AllSpacesList({ onPrev, }: AllSpacesListProps) { const { t } = useTranslation(); + const watchedIds = useWatchedSpaceIds(); const [settingsOpened, { open: openSettings, close: closeSettings }] = useDisclosure(false); const [selectedSpaceId, setSelectedSpaceId] = useState(null); @@ -65,7 +111,7 @@ export default function AllSpacesList({ {t("Space")} {t("Members")} - + @@ -117,8 +163,9 @@ export default function AllSpacesList({ - + + diff --git a/apps/client/src/features/space/queries/space-watcher-query.ts b/apps/client/src/features/space/queries/space-watcher-query.ts index ae4d5696..84642bb5 100644 --- a/apps/client/src/features/space/queries/space-watcher-query.ts +++ b/apps/client/src/features/space/queries/space-watcher-query.ts @@ -1,13 +1,27 @@ +import { useMemo } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { watchSpace, unwatchSpace, getSpaceWatchStatus, + getWatchedSpaceIds, } from "@/features/space/services/space-watcher-service"; import { notifications } from "@mantine/notifications"; import { useTranslation } from "react-i18next"; const SPACE_WATCHER_KEY = "space-watcher"; +const WATCHED_SPACE_IDS_KEY = "watched-space-ids"; + +export function useWatchedSpaceIds(): Set { + const { data } = useQuery({ + queryKey: [WATCHED_SPACE_IDS_KEY], + queryFn: () => getWatchedSpaceIds(), + refetchOnMount: true, + }); + + const items = data?.items; + return useMemo(() => new Set(items ?? []), [items]); +} export function useSpaceWatchStatusQuery(spaceId: string) { return useQuery({ @@ -27,6 +41,14 @@ export function useWatchSpaceMutation() { queryClient.setQueryData([SPACE_WATCHER_KEY, spaceId], { watching: true, }); + queryClient.setQueryData( + [WATCHED_SPACE_IDS_KEY], + (old: { items: string[]; meta: any } | undefined) => { + if (!old) return old; + if (old.items.includes(spaceId)) return old; + return { ...old, items: [...old.items, spaceId] }; + }, + ); notifications.show({ message: t("You are now watching this space") }); }, }); @@ -41,6 +63,13 @@ export function useUnwatchSpaceMutation() { queryClient.setQueryData([SPACE_WATCHER_KEY, spaceId], { watching: false, }); + queryClient.setQueryData( + [WATCHED_SPACE_IDS_KEY], + (old: { items: string[]; meta: any } | undefined) => { + if (!old) return old; + return { ...old, items: old.items.filter((id) => id !== spaceId) }; + }, + ); notifications.show({ message: t("You are no longer watching this space"), }); diff --git a/apps/client/src/features/space/services/space-watcher-service.ts b/apps/client/src/features/space/services/space-watcher-service.ts index bcbeccc9..35280c08 100644 --- a/apps/client/src/features/space/services/space-watcher-service.ts +++ b/apps/client/src/features/space/services/space-watcher-service.ts @@ -1,4 +1,5 @@ import api from "@/lib/api-client"; +import { IPagination } from "@/lib/types"; export async function watchSpace( spaceId: string, @@ -18,6 +19,11 @@ export async function unwatchSpace( return req.data; } +export async function getWatchedSpaceIds(): Promise> { + const req = await api.post>("/spaces/watched-ids"); + return req.data; +} + export async function getSpaceWatchStatus( spaceId: string, ): Promise<{ watching: boolean }> { diff --git a/apps/server/src/core/favorite/dto/favorite-ids.dto.ts b/apps/server/src/core/favorite/dto/favorite-ids.dto.ts new file mode 100644 index 00000000..1809024e --- /dev/null +++ b/apps/server/src/core/favorite/dto/favorite-ids.dto.ts @@ -0,0 +1,8 @@ +import { IsIn, IsNotEmpty, IsString } from 'class-validator'; + +export class FavoriteIdsDto { + @IsString() + @IsNotEmpty() + @IsIn(['page', 'space', 'template']) + type: 'page' | 'space' | 'template'; +} diff --git a/apps/server/src/core/favorite/favorite.controller.ts b/apps/server/src/core/favorite/favorite.controller.ts index e109f2c7..0cd56b08 100644 --- a/apps/server/src/core/favorite/favorite.controller.ts +++ b/apps/server/src/core/favorite/favorite.controller.ts @@ -11,6 +11,7 @@ import { } from '@nestjs/common'; import { FavoriteService } from './services/favorite.service'; import { AddFavoriteDto, RemoveFavoriteDto } from './dto/favorite.dto'; +import { FavoriteIdsDto } from './dto/favorite-ids.dto'; import { ListFavoritesDto } from './dto/list-favorites.dto'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; @@ -70,6 +71,20 @@ export class FavoriteController { }); } + @HttpCode(HttpStatus.OK) + @Post('ids') + async getFavoriteIds( + @Body() dto: FavoriteIdsDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + return this.favoriteService.getFavoriteIds( + user.id, + workspace.id, + dto.type as FavoriteType, + ); + } + @HttpCode(HttpStatus.OK) @Post() async getUserFavorites( diff --git a/apps/server/src/core/favorite/services/favorite.service.ts b/apps/server/src/core/favorite/services/favorite.service.ts index a4295fd2..1f72e92a 100644 --- a/apps/server/src/core/favorite/services/favorite.service.ts +++ b/apps/server/src/core/favorite/services/favorite.service.ts @@ -16,6 +16,40 @@ export class FavoriteService { private readonly spaceMemberRepo: SpaceMemberRepo, ) {} + async getFavoriteIds( + userId: string, + workspaceId: string, + type: FavoriteType, + ) { + const result = await this.favoriteRepo.getFavoriteIds( + userId, + workspaceId, + type, + ); + + if (result.items.length === 0) { + return result; + } + + if (type === FavoriteType.PAGE) { + const accessibleIds = + await this.pagePermissionRepo.filterAccessiblePageIds({ + pageIds: result.items, + userId, + }); + const accessibleSet = new Set(accessibleIds); + result.items = result.items.filter((id) => accessibleSet.has(id)); + } + + if (type === FavoriteType.SPACE) { + const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId); + const spaceSet = new Set(userSpaceIds); + result.items = result.items.filter((id) => spaceSet.has(id)); + } + + return result; + } + async addFavorite( userId: string, workspaceId: string, diff --git a/apps/server/src/core/watcher/space-watcher.controller.ts b/apps/server/src/core/watcher/space-watcher.controller.ts index 455c7d0d..f03a6041 100644 --- a/apps/server/src/core/watcher/space-watcher.controller.ts +++ b/apps/server/src/core/watcher/space-watcher.controller.ts @@ -48,6 +48,15 @@ export class SpaceWatcherController { return space; } + @HttpCode(HttpStatus.OK) + @Post('watched-ids') + async getWatchedSpaceIds( + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + return this.watcherService.getWatchedSpaceIds(user.id, workspace.id); + } + @HttpCode(HttpStatus.OK) @Post('watch') async watchSpace( diff --git a/apps/server/src/core/watcher/watcher.service.ts b/apps/server/src/core/watcher/watcher.service.ts index 3c5fe621..1f071a70 100644 --- a/apps/server/src/core/watcher/watcher.service.ts +++ b/apps/server/src/core/watcher/watcher.service.ts @@ -6,10 +6,14 @@ import { import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { KyselyTransaction } from '@docmost/db/types/kysely.types'; import { InsertableWatcher } from '@docmost/db/types/entity.types'; +import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; @Injectable() export class WatcherService { - constructor(private readonly watcherRepo: WatcherRepo) {} + constructor( + private readonly watcherRepo: WatcherRepo, + private readonly spaceMemberRepo: SpaceMemberRepo, + ) {} async watchPage( userId: string, @@ -84,6 +88,24 @@ export class WatcherService { return this.watcherRepo.deleteSpaceWatch(userId, spaceId); } + async getWatchedSpaceIds(userId: string, workspaceId: string) { + const result = await this.watcherRepo.getWatchedSpaceIds(userId, workspaceId); + + const spaceIds = result.items.map((r) => r.spaceId); + + if (spaceIds.length === 0) { + return { items: spaceIds, meta: result.meta }; + } + + const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId); + const spaceSet = new Set(userSpaceIds); + + return { + items: spaceIds.filter((id) => spaceSet.has(id)), + meta: result.meta, + }; + } + async isWatchingSpace(userId: string, spaceId: string): Promise { return this.watcherRepo.isWatchingSpace(userId, spaceId); } diff --git a/apps/server/src/database/repos/favorite/favorite.repo.ts b/apps/server/src/database/repos/favorite/favorite.repo.ts index 24c80343..35826225 100644 --- a/apps/server/src/database/repos/favorite/favorite.repo.ts +++ b/apps/server/src/database/repos/favorite/favorite.repo.ts @@ -62,6 +62,39 @@ export class FavoriteRepo { .execute(); } + async getFavoriteIds( + userId: string, + workspaceId: string, + type: FavoriteType, + ): Promise<{ items: string[]; meta: any }> { + const idColumn = + type === FavoriteType.PAGE + ? 'pageId' + : type === FavoriteType.SPACE + ? 'spaceId' + : 'templateId'; + + const query = this.db + .selectFrom('favorites') + .select(['favorites.id', `favorites.${idColumn} as entityId`]) + .where('userId', '=', userId) + .where('workspaceId', '=', workspaceId) + .where('type', '=', type); + + const result = await executeWithCursorPagination(query, { + perPage: 250, + fields: [{ expression: 'favorites.id', direction: 'desc' }], + parseCursor: (cursor) => ({ id: cursor.id }), + }); + + return { + items: result.items + .map((r) => (r as any).entityId as string) + .filter(Boolean), + meta: result.meta, + }; + } + async findUserFavorites( userId: string, workspaceId: string, diff --git a/apps/server/src/database/repos/watcher/watcher.repo.ts b/apps/server/src/database/repos/watcher/watcher.repo.ts index f1506ff9..16fa1fd5 100644 --- a/apps/server/src/database/repos/watcher/watcher.repo.ts +++ b/apps/server/src/database/repos/watcher/watcher.repo.ts @@ -207,6 +207,22 @@ export class WatcherRepo { .execute(); } + async getWatchedSpaceIds(userId: string, workspaceId: string) { + const query = this.db + .selectFrom('watchers') + .select(['watchers.id', 'watchers.spaceId']) + .where('userId', '=', userId) + .where('workspaceId', '=', workspaceId) + .where('pageId', 'is', null) + .where('type', '=', WatcherType.SPACE); + + return executeWithCursorPagination(query, { + perPage: 250, + fields: [{ expression: 'watchers.id', direction: 'asc' }], + parseCursor: (cursor) => ({ id: cursor.id }), + }); + } + async isWatchingSpace(userId: string, spaceId: string): Promise { const watcher = await this.db .selectFrom('watchers')