feat: search in shared pages

This commit is contained in:
Philipinho
2025-04-23 14:07:39 +01:00
parent ba3d6a37cf
commit 505334820f
13 changed files with 365 additions and 25 deletions

View File

@ -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);
}
}

View File

@ -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 (
<UnstyledButton {...others} className={cx(classes.root, className)}>
<Group gap="xs" wrap="nowrap">
<IconSearch style={{ width: rem(15), height: rem(15) }} stroke={1.5} />
<Text fz="sm" c="dimmed" pr={80}>
{t("Search")}
</Text>
<Text fw={700} className={classes.shortcut}>
Ctrl + K
</Text>
</Group>
</UnstyledButton>
);
}
interface SearchMobileControlProps {
onSearch: () => void;
}
export function SearchMobileControl({ onSearch }: SearchMobileControlProps) {
const { t } = useTranslation();
return (
<Tooltip label={t("Search")} withArrow>
<ActionIcon
variant="default"
style={{ border: "none" }}
onClick={onSearch}
size="sm"
>
<IconSearch size={20} stroke={2} />
</ActionIcon>
</Tooltip>
);
}

View File

@ -0,0 +1,7 @@
import { createSpotlight } from '@mantine/spotlight';
export const [searchSpotlightStore, searchSpotlight] = createSpotlight();
export const [shareSearchSpotlightStore, shareSearchSpotlight] =
createSpotlight();

View File

@ -1,6 +1,7 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query"; import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { import {
searchPage, searchPage,
searchShare,
searchSuggestions, searchSuggestions,
} from "@/features/search/services/search-service"; } from "@/features/search/services/search-service";
import { import {
@ -30,3 +31,13 @@ export function useSearchSuggestionsQuery(
enabled: !!params.query, enabled: !!params.query,
}); });
} }
export function useShareSearchQuery(
params: IPageSearchParams,
): UseQueryResult<IPageSearch[], Error> {
return useQuery({
queryKey: ["share-search", params],
queryFn: () => searchShare(params),
enabled: !!params.query,
});
}

View File

@ -8,6 +8,7 @@ import { usePageSearchQuery } from "@/features/search/queries/search-query";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
import { getPageIcon } from "@/lib"; import { getPageIcon } from "@/lib";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { searchSpotlightStore } from "./constants";
interface SearchSpotlightProps { interface SearchSpotlightProps {
spaceId?: string; spaceId?: string;
@ -18,11 +19,10 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [debouncedSearchQuery] = useDebouncedValue(query, 300); const [debouncedSearchQuery] = useDebouncedValue(query, 300);
const { const { data: searchResults } = usePageSearchQuery({
data: searchResults, query: debouncedSearchQuery,
isLoading, spaceId,
error, });
} = usePageSearchQuery({ query: debouncedSearchQuery, spaceId });
const pages = ( const pages = (
searchResults && searchResults.length > 0 ? searchResults : [] searchResults && searchResults.length > 0 ? searchResults : []
@ -54,6 +54,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
return ( return (
<> <>
<Spotlight.Root <Spotlight.Root
store={searchSpotlightStore}
query={query} query={query}
onQueryChange={setQuery} onQueryChange={setQuery}
scrollable scrollable

View File

@ -19,3 +19,10 @@ export async function searchSuggestions(
const req = await api.post<ISuggestionResult>("/search/suggest", params); const req = await api.post<ISuggestionResult>("/search/suggest", params);
return req.data; return req.data;
} }
export async function searchShare(
params: IPageSearchParams,
): Promise<IPageSearch[]> {
const req = await api.post<IPageSearch[]>("/search/share-search", params);
return req.data;
}

View File

@ -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) => (
<Spotlight.Action
key={page.id}
component={Link}
//@ts-ignore
to={buildSharedPageUrl({
shareId: shareId,
pageTitle: page.title,
pageSlugId: page.slugId,
})}
>
<Group wrap="nowrap" w="100%">
<Center>{getPageIcon(page?.icon)}</Center>
<div style={{ flex: 1 }}>
<Text>{page.title}</Text>
{page?.highlight && (
<Text
opacity={0.6}
size="xs"
dangerouslySetInnerHTML={{ __html: page.highlight }}
/>
)}
</div>
</Group>
</Spotlight.Action>
));
return (
<>
<Spotlight.Root
store={shareSearchSpotlightStore}
query={query}
onQueryChange={setQuery}
scrollable
overlayProps={{
backgroundOpacity: 0.55,
}}
>
<Spotlight.Search
placeholder={t("Search...")}
leftSection={<IconSearch size={20} stroke={1.5} />}
/>
<Spotlight.ActionsList>
{query.length === 0 && pages.length === 0 && (
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
)}
{query.length > 0 && pages.length === 0 && (
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
)}
{pages.length > 0 && pages}
</Spotlight.ActionsList>
</Spotlight.Root>
</>
);
}

View File

@ -35,4 +35,5 @@ export interface ISuggestionResult {
export interface IPageSearchParams { export interface IPageSearchParams {
query: string; query: string;
spaceId?: string; spaceId?: string;
shareId?: string;
} }

View File

@ -30,6 +30,12 @@ import {
import { IconList } from "@tabler/icons-react"; import { IconList } from "@tabler/icons-react";
import { useToggleToc } from "@/features/share/hooks/use-toggle-toc.ts"; import { useToggleToc } from "@/features/share/hooks/use-toggle-toc.ts";
import classes from "./share.module.css"; 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); const MemoizedSharedTree = React.memo(SharedTree);
@ -55,7 +61,7 @@ export default function ShareShell({
return ( return (
<AppShell <AppShell
header={{ height: 48 }} header={{ height: 50 }}
{...(data?.pageTree?.length > 1 && { {...(data?.pageTree?.length > 1 && {
navbar: { navbar: {
width: 300, width: 300,
@ -78,7 +84,7 @@ export default function ShareShell({
> >
<AppShell.Header> <AppShell.Header>
<Group wrap="nowrap" justify="space-between" py="sm" px="xl"> <Group wrap="nowrap" justify="space-between" py="sm" px="xl">
<Group> <Group wrap="nowrap">
{data?.pageTree?.length > 1 && ( {data?.pageTree?.length > 1 && (
<> <>
<Tooltip label={t("Sidebar toggle")}> <Tooltip label={t("Sidebar toggle")}>
@ -103,8 +109,21 @@ export default function ShareShell({
</> </>
)} )}
</Group> </Group>
{shareId && (
<Group visibleFrom="sm">
<SearchControl onClick={shareSearchSpotlight.open} />
</Group>
)}
<Group> <Group>
<> <>
{shareId && (
<Group hiddenFrom="sm">
<SearchMobileControl onSearch={shareSearchSpotlight.open} />
</Group>
)}
<Tooltip label={t("Table of contents")} withArrow> <Tooltip label={t("Table of contents")} withArrow>
<ActionIcon <ActionIcon
variant="default" variant="default"
@ -169,6 +188,8 @@ export default function ShareShell({
</div> </div>
</ScrollArea> </ScrollArea>
</AppShell.Aside> </AppShell.Aside>
<ShareSearchSpotlight shareId={shareId} />
</AppShell> </AppShell>
); );
} }

View File

@ -6,7 +6,6 @@ import {
Tooltip, Tooltip,
UnstyledButton, UnstyledButton,
} from "@mantine/core"; } from "@mantine/core";
import { spotlight } from "@mantine/spotlight";
import { import {
IconArrowDown, IconArrowDown,
IconDots, IconDots,
@ -16,9 +15,8 @@ import {
IconSearch, IconSearch,
IconSettings, IconSettings,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import classes from "./space-sidebar.module.css"; import classes from "./space-sidebar.module.css";
import React, { useMemo } from "react"; import React from "react";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { SearchSpotlight } from "@/features/search/search-spotlight.tsx"; import { SearchSpotlight } from "@/features/search/search-spotlight.tsx";
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts"; 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 ExportModal from "@/components/common/export-modal";
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";
export function SpaceSidebar() { export function SpaceSidebar() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -51,7 +50,7 @@ export function SpaceSidebar() {
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { data: space, isLoading, isError } = useGetSpaceBySlugQuery(spaceSlug); const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
const spaceRules = space?.membership?.permissions; const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules); const spaceAbility = useSpaceAbility(spaceRules);
@ -100,7 +99,10 @@ export function SpaceSidebar() {
</div> </div>
</UnstyledButton> </UnstyledButton>
<UnstyledButton className={classes.menu} onClick={spotlight.open}> <UnstyledButton
className={classes.menu}
onClick={searchSpotlight.open}
>
<div className={classes.menuItemInner}> <div className={classes.menuItemInner}>
<IconSearch <IconSearch
size={18} size={18}

View File

@ -5,8 +5,11 @@ import {
IsOptional, IsOptional,
IsString, IsString,
} from 'class-validator'; } from 'class-validator';
import { PartialType } from '@nestjs/mapped-types';
import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.dto';
export class SearchDTO { export class SearchDTO {
@IsNotEmpty()
@IsString() @IsString()
query: string; query: string;
@ -14,6 +17,10 @@ export class SearchDTO {
@IsString() @IsString()
spaceId: string; spaceId: string;
@IsOptional()
@IsString()
shareId?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
creatorId?: string; creatorId?: string;
@ -27,6 +34,16 @@ export class SearchDTO {
offset?: number; offset?: number;
} }
export class SearchShareDTO extends SearchDTO {
@IsNotEmpty()
@IsString()
shareId: string;
@IsOptional()
@IsString()
spaceId: string;
}
export class SearchSuggestionDTO { export class SearchSuggestionDTO {
@IsString() @IsString()
query: string; query: string;

View File

@ -1,15 +1,19 @@
import { import {
BadRequestException,
Body, Body,
Controller, Controller,
ForbiddenException, ForbiddenException,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
NotImplementedException,
Post, Post,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { SearchService } from './search.service'; import { SearchService } from './search.service';
import { SearchDTO, SearchSuggestionDTO } from './dto/search.dto'; import {
SearchDTO,
SearchShareDTO,
SearchSuggestionDTO,
} from './dto/search.dto';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types'; import { User, Workspace } from '@docmost/db/types/entity.types';
@ -19,6 +23,7 @@ import {
SpaceCaslSubject, SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type'; } from '../casl/interfaces/space-ability.type';
import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { Public } from 'src/common/decorators/public.decorator';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('search') @Controller('search')
@ -30,7 +35,13 @@ export class SearchController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post() @Post()
async pageSearch(@Body() searchDto: SearchDTO, @AuthUser() user: User) { async pageSearch(
@Body() searchDto: SearchDTO,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
delete searchDto.shareId;
if (searchDto.spaceId) { if (searchDto.spaceId) {
const ability = await this.spaceAbility.createForUser( const ability = await this.spaceAbility.createForUser(
user, user,
@ -40,12 +51,12 @@ export class SearchController {
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException(); throw new ForbiddenException();
} }
return this.searchService.searchPage(searchDto.query, searchDto);
} }
// TODO: search all spaces user is a member of if no spaceId provided return this.searchService.searchPage(searchDto.query, searchDto, {
throw new NotImplementedException(); userId: user.id,
workspaceId: workspace.id,
});
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ -57,4 +68,21 @@ export class SearchController {
) { ) {
return this.searchService.searchSuggestions(dto, user.id, workspace.id); return this.searchService.searchSuggestions(dto, user.id, workspace.id);
} }
@Public()
@HttpCode(HttpStatus.OK)
@Post('share-search')
async searchShare(
@Body() searchDto: SearchShareDTO,
@AuthWorkspace() workspace: Workspace,
) {
delete searchDto.spaceId;
if (!searchDto.shareId) {
throw new BadRequestException('shareId is required');
}
return this.searchService.searchPage(searchDto.query, searchDto, {
workspaceId: workspace.id,
});
}
} }

View File

@ -6,6 +6,7 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
import { sql } from 'kysely'; import { sql } from 'kysely';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
const tsquery = require('pg-tsquery')(); const tsquery = require('pg-tsquery')();
@ -15,19 +16,24 @@ export class SearchService {
constructor( constructor(
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
private pageRepo: PageRepo, private pageRepo: PageRepo,
private shareRepo: ShareRepo,
private spaceMemberRepo: SpaceMemberRepo, private spaceMemberRepo: SpaceMemberRepo,
) {} ) {}
async searchPage( async searchPage(
query: string, query: string,
searchParams: SearchDTO, searchParams: SearchDTO,
opts: {
userId?: string;
workspaceId: string;
},
): Promise<SearchResponseDto[]> { ): Promise<SearchResponseDto[]> {
if (query.length < 1) { if (query.length < 1) {
return; return;
} }
const searchQuery = tsquery(query.trim() + '*'); const searchQuery = tsquery(query.trim() + '*');
const queryResults = await this.db let queryResults = this.db
.selectFrom('pages') .selectFrom('pages')
.select([ .select([
'id', 'id',
@ -43,18 +49,71 @@ export class SearchService {
'highlight', 'highlight',
), ),
]) ])
.select((eb) => this.pageRepo.withSpace(eb))
.where('spaceId', '=', searchParams.spaceId)
.where('tsv', '@@', sql<string>`to_tsquery(${searchQuery})`) .where('tsv', '@@', sql<string>`to_tsquery(${searchQuery})`)
.$if(Boolean(searchParams.creatorId), (qb) => .$if(Boolean(searchParams.creatorId), (qb) =>
qb.where('creatorId', '=', searchParams.creatorId), qb.where('creatorId', '=', searchParams.creatorId),
) )
.orderBy('rank', 'desc') .orderBy('rank', 'desc')
.limit(searchParams.limit | 20) .limit(searchParams.limit | 20)
.offset(searchParams.offset || 0) .offset(searchParams.offset || 0);
.execute();
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) { if (result.highlight) {
result.highlight = result.highlight result.highlight = result.highlight
.replace(/\r\n|\r|\n/g, ' ') .replace(/\r\n|\r|\n/g, ' ')