From c26a851d52394e5c01717bf8674ae953ed7080e8 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Wed, 23 Apr 2025 14:32:35 +0100 Subject: [PATCH] feat: enhance public sharing (#1057) * fix tree nodes sort * remove comment mark in shares * remove clickoutside hook for now * feat: search in shared pages * fix user-select * use Link * render page icons --- .../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 | 21 ++--- .../search/services/search-service.ts | 7 ++ .../search/share-search-spotlight.tsx | 87 +++++++++++++++++++ .../src/features/search/types/search.types.ts | 1 + .../features/share/components/share-shell.tsx | 46 +++++----- .../features/share/components/shared-tree.tsx | 16 ++++ apps/client/src/features/share/utils.ts | 20 +++-- .../components/sidebar/space-sidebar.tsx | 12 +-- .../src/common/helpers/prosemirror/utils.ts | 13 +++ 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 +++++++++++++-- apps/server/src/core/share/share.service.ts | 10 +-- 17 files changed, 420 insertions(+), 61 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..581524fc 100644 --- a/apps/client/src/features/search/search-spotlight.tsx +++ b/apps/client/src/features/search/search-spotlight.tsx @@ -2,36 +2,36 @@ import { Group, Center, Text } from "@mantine/core"; import { Spotlight } from "@mantine/spotlight"; import { IconSearch } from "@tabler/icons-react"; import React, { useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { Link } from "react-router-dom"; import { useDebouncedValue } from "@mantine/hooks"; 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; } export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { const { t } = useTranslation(); - const navigate = useNavigate(); 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 : [] ).map((page) => ( - navigate(buildPageUrl(page.space.slug, page.slugId, page.title)) - } + component={Link} + //@ts-ignore + to={buildPageUrl(page.space.slug, page.slugId, page.title)} + style={{ userSelect: "none" }} >
{getPageIcon(page?.icon)}
@@ -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..bfbced6e --- /dev/null +++ b/apps/client/src/features/search/share-search-spotlight.tsx @@ -0,0 +1,87 @@ +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 799958d2..7fa0c941 100644 --- a/apps/client/src/features/share/components/share-shell.tsx +++ b/apps/client/src/features/share/components/share-shell.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React from "react"; import { ActionIcon, Affix, @@ -30,7 +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 { useClickOutside } from "@mantine/hooks"; +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); @@ -54,21 +59,9 @@ export default function ShareShell({ const { data } = useGetSharedPageTreeQuery(shareId); const readOnlyEditor = useAtomValue(readOnlyEditorAtom); - const [navbarOutside, setNavbarOutside] = useState(null); - - useClickOutside( - () => { - if (mobileOpened) { - toggleMobile(); - } - }, - null, - [navbarOutside], - ); - return ( 1 && { navbar: { width: 300, @@ -91,7 +84,7 @@ export default function ShareShell({ > - + {data?.pageTree?.length > 1 && ( <> @@ -116,8 +109,21 @@ export default function ShareShell({ )} + + {shareId && ( + + + + )} + <> + {shareId && ( + + + + )} + {data?.pageTree?.length > 1 && ( - + )} @@ -186,6 +188,8 @@ export default function ShareShell({ + + ); } diff --git a/apps/client/src/features/share/components/shared-tree.tsx b/apps/client/src/features/share/components/shared-tree.tsx index 5e85ab57..486127b9 100644 --- a/apps/client/src/features/share/components/shared-tree.tsx +++ b/apps/client/src/features/share/components/shared-tree.tsx @@ -15,6 +15,7 @@ import clsx from "clsx"; import { IconChevronDown, IconChevronRight, + IconFileDescription, IconPointFilled, } from "@tabler/icons-react"; import { ActionIcon, Box } from "@mantine/core"; @@ -23,6 +24,7 @@ import { OpenMap } from "react-arborist/dist/main/state/open-slice"; import classes from "@/features/page/tree/styles/tree.module.css"; import styles from "./share.module.css"; import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; +import EmojiPicker from "@/components/ui/emoji-picker.tsx"; interface SharedTree { sharedPageTree: ISharedPageTree; @@ -141,6 +143,20 @@ function Node({ node, style, tree }: NodeRendererProps) { }} > +
+ {}} + icon={ + node.data.icon ? ( + node.data.icon + ) : ( + + ) + } + readOnly={true} + removeEmojiAction={() => {}} + /> +
{node.data.name || t("untitled")} diff --git a/apps/client/src/features/share/utils.ts b/apps/client/src/features/share/utils.ts index 74ec349f..cc73eb57 100644 --- a/apps/client/src/features/share/utils.ts +++ b/apps/client/src/features/share/utils.ts @@ -11,11 +11,13 @@ export type SharedPageTreeNode = { parentPageId: string; hasChildren: boolean; children: SharedPageTreeNode[]; - label: string, - value: string, + label: string; + value: string; }; -export function buildSharedPageTree(pages: Partial): SharedPageTreeNode[] { +export function buildSharedPageTree( + pages: Partial, +): SharedPageTreeNode[] { const pageMap: Record = {}; // Initialize each page as a tree node and store it in a map. @@ -30,7 +32,7 @@ export function buildSharedPageTree(pages: Partial): SharedPageTreeNode hasChildren: false, spaceId: page.spaceId, parentPageId: page.parentPageId, - label: page.title || 'untitled', + label: page.title || "untitled", value: page.id, children: [], }; @@ -55,6 +57,12 @@ export function buildSharedPageTree(pages: Partial): SharedPageTreeNode } }); - // Return the sorted tree. - return sortPositionKeys(tree); + function sortTree(nodes: SharedPageTreeNode[]): SharedPageTreeNode[] { + return sortPositionKeys(nodes).map((node: SharedPageTreeNode) => ({ + ...node, + children: sortTree(node.children), + })); + } + + return sortTree(tree); } 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, ' ') diff --git a/apps/server/src/core/share/share.service.ts b/apps/server/src/core/share/share.service.ts index a9140c0b..d71b6acd 100644 --- a/apps/server/src/core/share/share.service.ts +++ b/apps/server/src/core/share/share.service.ts @@ -15,6 +15,7 @@ import { getAttachmentIds, getProsemirrorContent, isAttachmentNode, + removeMarkTypeFromDoc, } from '../../common/helpers/prosemirror/utils'; import { Node } from '@tiptap/pm/model'; import { ShareRepo } from '@docmost/db/repos/share/share.repo'; @@ -223,11 +224,7 @@ export class ShareService { .end() .as('found'), ]) - .where( - isValidUUID(childPageId) ? 'id' : 'slugId', - '=', - childPageId, - ) + .where(isValidUUID(childPageId) ? 'id' : 'slugId', '=', childPageId) .unionAll((exp) => exp .selectFrom('pages as p') @@ -292,6 +289,7 @@ export class ShareService { updateAttachmentAttr(node, 'url', token); }); - return doc.toJSON(); + const removeCommentMarks = removeMarkTypeFromDoc(doc, 'comment'); + return removeCommentMarks.toJSON(); } }