This commit is contained in:
Philipinho
2026-04-13 23:12:02 +01:00
parent bd68e47e03
commit de09ce7520
13 changed files with 306 additions and 27 deletions
@@ -1,3 +1,4 @@
import { useMemo } from "react";
import { import {
useQuery, useQuery,
useInfiniteQuery, useInfiniteQuery,
@@ -8,10 +9,10 @@ import {
addFavorite, addFavorite,
removeFavorite, removeFavorite,
getFavorites, getFavorites,
getFavoriteIds,
ToggleFavoriteParams, ToggleFavoriteParams,
} from "../services/favorite-service"; } from "../services/favorite-service";
import { IPagination } from "@/lib/types.ts"; import { FavoriteType } from "../types/favorite.types";
import { IFavorite, FavoriteType } from "../types/favorite.types";
export function useFavoritesQuery(type?: FavoriteType) { export function useFavoritesQuery(type?: FavoriteType) {
return useInfiniteQuery({ return useInfiniteQuery({
@@ -26,23 +27,21 @@ export function useFavoritesQuery(type?: FavoriteType) {
} }
export function useFavoriteIds(type: FavoriteType): Set<string> { export function useFavoriteIds(type: FavoriteType): Set<string> {
const { data } = useQuery<IPagination<IFavorite>>({ const { data } = useQuery({
queryKey: ["favorite-ids", type], queryKey: ["favorite-ids", type],
queryFn: () => getFavorites({ type, limit: 50 }), queryFn: () => getFavoriteIds(type),
refetchOnMount: true, refetchOnMount: true,
}); });
const ids = new Set<string>(); const items = data?.items;
if (data?.items) { return useMemo(() => new Set(items ?? []), [items]);
for (const fav of data.items) { }
let id: string | undefined;
if (type === "page") id = fav.pageId; function getEntityId(variables: ToggleFavoriteParams): string | undefined {
else if (type === "space") id = fav.spaceId; if (variables.type === "page") return variables.pageId;
else if (type === "template") id = fav.templateId; if (variables.type === "space") return variables.spaceId;
if (id) ids.add(id); if (variables.type === "template") return variables.templateId;
} return undefined;
}
return ids;
} }
export function useAddFavoriteMutation() { export function useAddFavoriteMutation() {
@@ -51,9 +50,17 @@ export function useAddFavoriteMutation() {
return useMutation<void, Error, ToggleFavoriteParams>({ return useMutation<void, Error, ToggleFavoriteParams>({
mutationFn: (data) => addFavorite(data), mutationFn: (data) => addFavorite(data),
onSuccess: (_result, variables) => { onSuccess: (_result, variables) => {
queryClient.invalidateQueries({ const entityId = getEntityId(variables);
queryKey: ["favorite-ids", variables.type], 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({ queryClient.invalidateQueries({
queryKey: ["favorites", variables.type], queryKey: ["favorites", variables.type],
}); });
@@ -67,9 +74,16 @@ export function useRemoveFavoriteMutation() {
return useMutation<void, Error, ToggleFavoriteParams>({ return useMutation<void, Error, ToggleFavoriteParams>({
mutationFn: (data) => removeFavorite(data), mutationFn: (data) => removeFavorite(data),
onSuccess: (_result, variables) => { onSuccess: (_result, variables) => {
queryClient.invalidateQueries({ const entityId = getEntityId(variables);
queryKey: ["favorite-ids", variables.type], 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({ queryClient.invalidateQueries({
queryKey: ["favorites", variables.type], queryKey: ["favorites", variables.type],
}); });
@@ -21,6 +21,11 @@ export async function removeFavorite(
await api.post("/favorites/remove", params); await api.post("/favorites/remove", params);
} }
export async function getFavoriteIds(type: FavoriteType): Promise<IPagination<string>> {
const req = await api.post<IPagination<string>>("/favorites/ids", { type });
return req.data;
}
export async function getFavorites(params?: { export async function getFavorites(params?: {
type?: FavoriteType; type?: FavoriteType;
limit?: number; limit?: number;
@@ -16,6 +16,8 @@ import {
IconPlus, IconPlus,
IconSearch, IconSearch,
IconSettings, IconSettings,
IconStar,
IconStarFilled,
IconTrash, IconTrash,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { import {
@@ -43,6 +45,11 @@ import PageImportModal from "@/features/page/components/page-import-modal.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { SwitchSpace } from "./switch-space"; import { SwitchSpace } from "./switch-space";
import ExportModal from "@/components/common/export-modal"; 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 { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import { searchSpotlight } from "@/features/search/constants"; import { searchSpotlight } from "@/features/search/constants";
@@ -56,7 +63,6 @@ export function SpaceSidebar() {
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom); const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug); const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
@@ -82,7 +88,12 @@ export function SpaceSidebar() {
marginBottom: 3, marginBottom: 3,
}} }}
> >
<Group gap={4} wrap="nowrap" justify="space-between" style={{ width: "100%" }}> <Group
gap={4}
wrap="nowrap"
justify="space-between"
style={{ width: "100%" }}
>
<SwitchSpace <SwitchSpace
spaceName={space?.name} spaceName={space?.name}
spaceSlug={space?.slug} spaceSlug={space?.slug}
@@ -241,6 +252,20 @@ function SpaceMenu({
const unwatchMutation = useUnwatchSpaceMutation(); const unwatchMutation = useUnwatchSpaceMutation();
const isWatching = watchStatus?.watching ?? false; const isWatching = watchStatus?.watching ?? false;
const favoriteIds = useFavoriteIds("space");
const addFavoriteMutation = useAddFavoriteMutation();
const removeFavoriteMutation = useRemoveFavoriteMutation();
const isFavorited = favoriteIds.has(spaceId);
const handleToggleFavorite = () => {
const params = { type: "space" as const, spaceId };
if (isFavorited) {
removeFavoriteMutation.mutate(params);
} else {
addFavoriteMutation.mutate(params);
}
};
const handleToggleWatch = () => { const handleToggleWatch = () => {
if (isWatching) { if (isWatching) {
unwatchMutation.mutate(spaceId); unwatchMutation.mutate(spaceId);
@@ -265,6 +290,22 @@ function SpaceMenu({
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item
onClick={handleToggleFavorite}
leftSection={
isFavorited ? (
<IconStarFilled
size={16}
color="var(--mantine-color-yellow-filled)"
/>
) : (
<IconStar size={16} />
)
}
>
{isFavorited ? t("Remove from favorites") : t("Add to favorites")}
</Menu.Item>
<Menu.Item <Menu.Item
onClick={handleToggleWatch} onClick={handleToggleWatch}
leftSection={ leftSection={
@@ -7,9 +7,15 @@ import {
Space, Space,
Menu, Menu,
Anchor, Anchor,
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { IconDots, IconSettings } from "@tabler/icons-react"; import { IconDots, IconSettings, IconEye, IconEyeOff } from "@tabler/icons-react";
import StarButton from "@/features/favorite/components/star-button"; import StarButton from "@/features/favorite/components/star-button";
import {
useWatchedSpaceIds,
useWatchSpaceMutation,
useUnwatchSpaceMutation,
} from "@/features/space/queries/space-watcher-query";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import React, { useState } from "react"; import React, { useState } from "react";
@@ -26,6 +32,45 @@ import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx"; import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
function WatchButton({ spaceId, watchedIds, size = 16 }: { spaceId: string; watchedIds: Set<string>; 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 (
<Tooltip
label={isWatching ? t("Stop watching space") : t("Watch space")}
openDelay={250}
withArrow
>
<ActionIcon
variant="subtle"
color={isWatching ? "blue" : "gray"}
onClick={handleToggle}
loading={isPending}
>
{isWatching ? (
<IconEyeOff size={size} stroke={2} />
) : (
<IconEye size={size} stroke={2} />
)}
</ActionIcon>
</Tooltip>
);
}
interface AllSpacesListProps { interface AllSpacesListProps {
spaces: any[]; spaces: any[];
onSearch: (query: string) => void; onSearch: (query: string) => void;
@@ -44,6 +89,7 @@ export default function AllSpacesList({
onPrev, onPrev,
}: AllSpacesListProps) { }: AllSpacesListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const watchedIds = useWatchedSpaceIds();
const [settingsOpened, { open: openSettings, close: closeSettings }] = const [settingsOpened, { open: openSettings, close: closeSettings }] =
useDisclosure(false); useDisclosure(false);
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(null); const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(null);
@@ -65,7 +111,7 @@ export default function AllSpacesList({
<Table.Tr> <Table.Tr>
<Table.Th>{t("Space")}</Table.Th> <Table.Th>{t("Space")}</Table.Th>
<Table.Th>{t("Members")}</Table.Th> <Table.Th>{t("Members")}</Table.Th>
<Table.Th w={100}></Table.Th> <Table.Th w={130}></Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
@@ -117,8 +163,9 @@ export default function AllSpacesList({
</Text> </Text>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap="xs" justify="flex-end"> <Group gap="xs" justify="flex-end" wrap="nowrap">
<StarButton type="space" spaceId={space.id} size={16} /> <StarButton type="space" spaceId={space.id} size={16} />
<WatchButton spaceId={space.id} watchedIds={watchedIds} size={16} />
<Menu position="bottom-end"> <Menu position="bottom-end">
<Menu.Target> <Menu.Target>
<ActionIcon variant="subtle" color="gray"> <ActionIcon variant="subtle" color="gray">
@@ -1,13 +1,27 @@
import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
watchSpace, watchSpace,
unwatchSpace, unwatchSpace,
getSpaceWatchStatus, getSpaceWatchStatus,
getWatchedSpaceIds,
} from "@/features/space/services/space-watcher-service"; } from "@/features/space/services/space-watcher-service";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const SPACE_WATCHER_KEY = "space-watcher"; const SPACE_WATCHER_KEY = "space-watcher";
const WATCHED_SPACE_IDS_KEY = "watched-space-ids";
export function useWatchedSpaceIds(): Set<string> {
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) { export function useSpaceWatchStatusQuery(spaceId: string) {
return useQuery({ return useQuery({
@@ -27,6 +41,14 @@ export function useWatchSpaceMutation() {
queryClient.setQueryData([SPACE_WATCHER_KEY, spaceId], { queryClient.setQueryData([SPACE_WATCHER_KEY, spaceId], {
watching: true, 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") }); notifications.show({ message: t("You are now watching this space") });
}, },
}); });
@@ -41,6 +63,13 @@ export function useUnwatchSpaceMutation() {
queryClient.setQueryData([SPACE_WATCHER_KEY, spaceId], { queryClient.setQueryData([SPACE_WATCHER_KEY, spaceId], {
watching: false, 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({ notifications.show({
message: t("You are no longer watching this space"), message: t("You are no longer watching this space"),
}); });
@@ -1,4 +1,5 @@
import api from "@/lib/api-client"; import api from "@/lib/api-client";
import { IPagination } from "@/lib/types";
export async function watchSpace( export async function watchSpace(
spaceId: string, spaceId: string,
@@ -18,6 +19,11 @@ export async function unwatchSpace(
return req.data; return req.data;
} }
export async function getWatchedSpaceIds(): Promise<IPagination<string>> {
const req = await api.post<IPagination<string>>("/spaces/watched-ids");
return req.data;
}
export async function getSpaceWatchStatus( export async function getSpaceWatchStatus(
spaceId: string, spaceId: string,
): Promise<{ watching: boolean }> { ): Promise<{ watching: boolean }> {
@@ -0,0 +1,8 @@
import { IsIn, IsNotEmpty, IsString } from 'class-validator';
export class FavoriteIdsDto {
@IsString()
@IsNotEmpty()
@IsIn(['page', 'space', 'template'])
type: 'page' | 'space' | 'template';
}
@@ -11,6 +11,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { FavoriteService } from './services/favorite.service'; import { FavoriteService } from './services/favorite.service';
import { AddFavoriteDto, RemoveFavoriteDto } from './dto/favorite.dto'; import { AddFavoriteDto, RemoveFavoriteDto } from './dto/favorite.dto';
import { FavoriteIdsDto } from './dto/favorite-ids.dto';
import { ListFavoritesDto } from './dto/list-favorites.dto'; import { ListFavoritesDto } from './dto/list-favorites.dto';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; 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) @HttpCode(HttpStatus.OK)
@Post() @Post()
async getUserFavorites( async getUserFavorites(
@@ -16,6 +16,40 @@ export class FavoriteService {
private readonly spaceMemberRepo: SpaceMemberRepo, 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( async addFavorite(
userId: string, userId: string,
workspaceId: string, workspaceId: string,
@@ -48,6 +48,15 @@ export class SpaceWatcherController {
return space; 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) @HttpCode(HttpStatus.OK)
@Post('watch') @Post('watch')
async watchSpace( async watchSpace(
@@ -6,10 +6,14 @@ import {
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { KyselyTransaction } from '@docmost/db/types/kysely.types'; import { KyselyTransaction } from '@docmost/db/types/kysely.types';
import { InsertableWatcher } from '@docmost/db/types/entity.types'; import { InsertableWatcher } from '@docmost/db/types/entity.types';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
@Injectable() @Injectable()
export class WatcherService { export class WatcherService {
constructor(private readonly watcherRepo: WatcherRepo) {} constructor(
private readonly watcherRepo: WatcherRepo,
private readonly spaceMemberRepo: SpaceMemberRepo,
) {}
async watchPage( async watchPage(
userId: string, userId: string,
@@ -84,6 +88,24 @@ export class WatcherService {
return this.watcherRepo.deleteSpaceWatch(userId, spaceId); 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<boolean> { async isWatchingSpace(userId: string, spaceId: string): Promise<boolean> {
return this.watcherRepo.isWatchingSpace(userId, spaceId); return this.watcherRepo.isWatchingSpace(userId, spaceId);
} }
@@ -62,6 +62,39 @@ export class FavoriteRepo {
.execute(); .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( async findUserFavorites(
userId: string, userId: string,
workspaceId: string, workspaceId: string,
@@ -207,6 +207,22 @@ export class WatcherRepo {
.execute(); .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<boolean> { async isWatchingSpace(userId: string, spaceId: string): Promise<boolean> {
const watcher = await this.db const watcher = await this.db
.selectFrom('watchers') .selectFrom('watchers')