From 505334820ffe5953fa83ed96fb1d31ed8a2f6386 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Wed, 23 Apr 2025 14:07:39 +0100 Subject: [PATCH] feat: search in shared pages --- .../components/search-control.module.css | 44 ++++++++++ .../search/components/search-control.tsx | 56 ++++++++++++ apps/client/src/features/search/constants.ts | 7 ++ .../features/search/queries/search-query.ts | 11 +++ .../src/features/search/search-spotlight.tsx | 11 +-- .../search/services/search-service.ts | 7 ++ .../search/share-search-spotlight.tsx | 86 +++++++++++++++++++ .../src/features/search/types/search.types.ts | 1 + .../features/share/components/share-shell.tsx | 25 +++++- .../components/sidebar/space-sidebar.tsx | 12 +-- apps/server/src/core/search/dto/search.dto.ts | 17 ++++ .../src/core/search/search.controller.ts | 42 +++++++-- apps/server/src/core/search/search.service.ts | 71 +++++++++++++-- 13 files changed, 365 insertions(+), 25 deletions(-) create mode 100644 apps/client/src/features/search/components/search-control.module.css create mode 100644 apps/client/src/features/search/components/search-control.tsx create mode 100644 apps/client/src/features/search/constants.ts create mode 100644 apps/client/src/features/search/share-search-spotlight.tsx diff --git a/apps/client/src/features/search/components/search-control.module.css b/apps/client/src/features/search/components/search-control.module.css new file mode 100644 index 00000000..5e5a9c26 --- /dev/null +++ b/apps/client/src/features/search/components/search-control.module.css @@ -0,0 +1,44 @@ +.root { + height: 34px; + padding-left: var(--mantine-spacing-sm); + padding-right: 4px; + border-radius: var(--mantine-radius-md); + color: var(--mantine-color-placeholder); + border: 1px solid; + + @mixin light { + border-color: var(--mantine-color-gray-3); + background-color: var(--mantine-color-white); + } + + @mixin dark { + border-color: var(--mantine-color-dark-4); + background-color: var(--mantine-color-dark-6); + } + + @mixin rtl { + padding-left: 4px; + padding-right: var(--mantine-spacing-sm); + } +} + +.shortcut { + font-size: 11px; + line-height: 1; + padding: 4px 7px; + border-radius: var(--mantine-radius-sm); + border: 1px solid; + font-weight: bold; + + @mixin light { + color: var(--mantine-color-gray-7); + border-color: var(--mantine-color-gray-2); + background-color: var(--mantine-color-gray-0); + } + + @mixin dark { + color: var(--mantine-color-dark-0); + border-color: var(--mantine-color-dark-7); + background-color: var(--mantine-color-dark-7); + } +} \ No newline at end of file diff --git a/apps/client/src/features/search/components/search-control.tsx b/apps/client/src/features/search/components/search-control.tsx new file mode 100644 index 00000000..3ae74da2 --- /dev/null +++ b/apps/client/src/features/search/components/search-control.tsx @@ -0,0 +1,56 @@ +import { IconSearch } from "@tabler/icons-react"; +import cx from "clsx"; +import { + ActionIcon, + BoxProps, + ElementProps, + Group, + rem, + Text, + Tooltip, + UnstyledButton, +} from "@mantine/core"; +import classes from "./search-control.module.css"; +import React from "react"; +import { useTranslation } from "react-i18next"; + +interface SearchControlProps extends BoxProps, ElementProps<"button"> {} + +export function SearchControl({ className, ...others }: SearchControlProps) { + const { t } = useTranslation(); + + return ( + + + + + {t("Search")} + + + Ctrl + K + + + + ); +} + +interface SearchMobileControlProps { + onSearch: () => void; +} + +export function SearchMobileControl({ onSearch }: SearchMobileControlProps) { + const { t } = useTranslation(); + + return ( + + + + + + ); +} diff --git a/apps/client/src/features/search/constants.ts b/apps/client/src/features/search/constants.ts new file mode 100644 index 00000000..a4c6c2f7 --- /dev/null +++ b/apps/client/src/features/search/constants.ts @@ -0,0 +1,7 @@ +import { createSpotlight } from '@mantine/spotlight'; + +export const [searchSpotlightStore, searchSpotlight] = createSpotlight(); + +export const [shareSearchSpotlightStore, shareSearchSpotlight] = + createSpotlight(); + diff --git a/apps/client/src/features/search/queries/search-query.ts b/apps/client/src/features/search/queries/search-query.ts index 2505e7a2..6b8c0296 100644 --- a/apps/client/src/features/search/queries/search-query.ts +++ b/apps/client/src/features/search/queries/search-query.ts @@ -1,6 +1,7 @@ import { useQuery, UseQueryResult } from "@tanstack/react-query"; import { searchPage, + searchShare, searchSuggestions, } from "@/features/search/services/search-service"; import { @@ -30,3 +31,13 @@ export function useSearchSuggestionsQuery( enabled: !!params.query, }); } + +export function useShareSearchQuery( + params: IPageSearchParams, +): UseQueryResult { + return useQuery({ + queryKey: ["share-search", params], + queryFn: () => searchShare(params), + enabled: !!params.query, + }); +} diff --git a/apps/client/src/features/search/search-spotlight.tsx b/apps/client/src/features/search/search-spotlight.tsx index 52e24557..6a719cc1 100644 --- a/apps/client/src/features/search/search-spotlight.tsx +++ b/apps/client/src/features/search/search-spotlight.tsx @@ -8,6 +8,7 @@ import { usePageSearchQuery } from "@/features/search/queries/search-query"; import { buildPageUrl } from "@/features/page/page.utils.ts"; import { getPageIcon } from "@/lib"; import { useTranslation } from "react-i18next"; +import { searchSpotlightStore } from "./constants"; interface SearchSpotlightProps { spaceId?: string; @@ -18,11 +19,10 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { const [query, setQuery] = useState(""); const [debouncedSearchQuery] = useDebouncedValue(query, 300); - const { - data: searchResults, - isLoading, - error, - } = usePageSearchQuery({ query: debouncedSearchQuery, spaceId }); + const { data: searchResults } = usePageSearchQuery({ + query: debouncedSearchQuery, + spaceId, + }); const pages = ( searchResults && searchResults.length > 0 ? searchResults : [] @@ -54,6 +54,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { return ( <> ("/search/suggest", params); return req.data; } + +export async function searchShare( + params: IPageSearchParams, +): Promise { + const req = await api.post("/search/share-search", params); + return req.data; +} diff --git a/apps/client/src/features/search/share-search-spotlight.tsx b/apps/client/src/features/search/share-search-spotlight.tsx new file mode 100644 index 00000000..2f84524e --- /dev/null +++ b/apps/client/src/features/search/share-search-spotlight.tsx @@ -0,0 +1,86 @@ +import { Group, Center, Text } from "@mantine/core"; +import { Spotlight } from "@mantine/spotlight"; +import { IconSearch } from "@tabler/icons-react"; +import React, { useState } from "react"; +import { Link } from "react-router-dom"; +import { useDebouncedValue } from "@mantine/hooks"; +import { useShareSearchQuery } from "@/features/search/queries/search-query"; +import { buildSharedPageUrl } from "@/features/page/page.utils.ts"; +import { getPageIcon } from "@/lib"; +import { useTranslation } from "react-i18next"; +import { shareSearchSpotlightStore } from "@/features/search/constants.ts"; + +interface ShareSearchSpotlightProps { + shareId?: string; +} +export function ShareSearchSpotlight({ shareId }: ShareSearchSpotlightProps) { + const { t } = useTranslation(); + const [query, setQuery] = useState(""); + const [debouncedSearchQuery] = useDebouncedValue(query, 300); + + const { data: searchResults } = useShareSearchQuery({ + query: debouncedSearchQuery, + shareId, + }); + + const pages = ( + searchResults && searchResults.length > 0 ? searchResults : [] + ).map((page) => ( + + +
{getPageIcon(page?.icon)}
+ +
+ {page.title} + + {page?.highlight && ( + + )} +
+
+
+ )); + + return ( + <> + + } + /> + + {query.length === 0 && pages.length === 0 && ( + {t("Start typing to search...")} + )} + + {query.length > 0 && pages.length === 0 && ( + {t("No results found...")} + )} + + {pages.length > 0 && pages} + + + + ); +} diff --git a/apps/client/src/features/search/types/search.types.ts b/apps/client/src/features/search/types/search.types.ts index 5a346f6b..1338e121 100644 --- a/apps/client/src/features/search/types/search.types.ts +++ b/apps/client/src/features/search/types/search.types.ts @@ -35,4 +35,5 @@ export interface ISuggestionResult { export interface IPageSearchParams { query: string; spaceId?: string; + shareId?: string; } diff --git a/apps/client/src/features/share/components/share-shell.tsx b/apps/client/src/features/share/components/share-shell.tsx index 2a0ad8c3..7fa0c941 100644 --- a/apps/client/src/features/share/components/share-shell.tsx +++ b/apps/client/src/features/share/components/share-shell.tsx @@ -30,6 +30,12 @@ import { import { IconList } from "@tabler/icons-react"; import { useToggleToc } from "@/features/share/hooks/use-toggle-toc.ts"; import classes from "./share.module.css"; +import { + SearchControl, + SearchMobileControl, +} from "@/features/search/components/search-control.tsx"; +import { ShareSearchSpotlight } from "@/features/search/share-search-spotlight"; +import { shareSearchSpotlight } from "@/features/search/constants"; const MemoizedSharedTree = React.memo(SharedTree); @@ -55,7 +61,7 @@ export default function ShareShell({ return ( 1 && { navbar: { width: 300, @@ -78,7 +84,7 @@ export default function ShareShell({ > - + {data?.pageTree?.length > 1 && ( <> @@ -103,8 +109,21 @@ export default function ShareShell({ )} + + {shareId && ( + + + + )} + <> + {shareId && ( + + + + )} + + + ); } 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 528e8051..5dbd420a 100644 --- a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx +++ b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx @@ -6,7 +6,6 @@ import { Tooltip, UnstyledButton, } from "@mantine/core"; -import { spotlight } from "@mantine/spotlight"; import { IconArrowDown, IconDots, @@ -16,9 +15,8 @@ import { IconSearch, IconSettings, } from "@tabler/icons-react"; - import classes from "./space-sidebar.module.css"; -import React, { useMemo } from "react"; +import React from "react"; import { useAtom } from "jotai"; import { SearchSpotlight } from "@/features/search/search-spotlight.tsx"; import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts"; @@ -40,6 +38,7 @@ import { SwitchSpace } from "./switch-space"; import ExportModal from "@/components/common/export-modal"; 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"; export function SpaceSidebar() { const { t } = useTranslation(); @@ -51,7 +50,7 @@ export function SpaceSidebar() { const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); const { spaceSlug } = useParams(); - const { data: space, isLoading, isError } = useGetSpaceBySlugQuery(spaceSlug); + const { data: space } = useGetSpaceBySlugQuery(spaceSlug); const spaceRules = space?.membership?.permissions; const spaceAbility = useSpaceAbility(spaceRules); @@ -100,7 +99,10 @@ export function SpaceSidebar() { - +
{ if (query.length < 1) { return; } const searchQuery = tsquery(query.trim() + '*'); - const queryResults = await this.db + let queryResults = this.db .selectFrom('pages') .select([ 'id', @@ -43,18 +49,71 @@ export class SearchService { 'highlight', ), ]) - .select((eb) => this.pageRepo.withSpace(eb)) - .where('spaceId', '=', searchParams.spaceId) .where('tsv', '@@', sql`to_tsquery(${searchQuery})`) .$if(Boolean(searchParams.creatorId), (qb) => qb.where('creatorId', '=', searchParams.creatorId), ) .orderBy('rank', 'desc') .limit(searchParams.limit | 20) - .offset(searchParams.offset || 0) - .execute(); + .offset(searchParams.offset || 0); - const searchResults = queryResults.map((result) => { + if (!searchParams.shareId) { + queryResults = queryResults.select((eb) => this.pageRepo.withSpace(eb)); + } + + if (searchParams.spaceId) { + // search by spaceId + queryResults = queryResults.where('spaceId', '=', searchParams.spaceId); + } else if (opts.userId && !searchParams.spaceId) { + // only search spaces the user is a member of + const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds( + opts.userId, + ); + if (userSpaceIds.length > 0) { + queryResults = queryResults + .where('spaceId', 'in', userSpaceIds) + .where('workspaceId', '=', opts.workspaceId); + } else { + return []; + } + } else if (searchParams.shareId && !searchParams.spaceId && !opts.userId) { + // search in shares + const shareId = searchParams.shareId; + const share = await this.shareRepo.findById(shareId); + if (!share || share.workspaceId !== opts.workspaceId) { + return []; + } + + const pageIdsToSearch = []; + if (share.includeSubPages) { + const pageList = await this.pageRepo.getPageAndDescendants( + share.pageId, + { + includeContent: false, + }, + ); + + pageIdsToSearch.push(...pageList.map((page) => page.id)); + } else { + pageIdsToSearch.push(share.pageId); + } + + if (pageIdsToSearch.length > 0) { + queryResults = queryResults + .where('id', 'in', pageIdsToSearch) + .where('workspaceId', '=', opts.workspaceId); + } else { + return []; + } + } else { + return []; + } + + //@ts-ignore + queryResults = await queryResults.execute(); + + //@ts-ignore + const searchResults = queryResults.map((result: SearchResponseDto) => { if (result.highlight) { result.highlight = result.highlight .replace(/\r\n|\r|\n/g, ' ')