mirror of
https://github.com/docmost/docmost.git
synced 2025-11-18 18:41:11 +10:00
feat: search in shared pages
This commit is contained in:
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
apps/client/src/features/search/constants.ts
Normal file
7
apps/client/src/features/search/constants.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { createSpotlight } from '@mantine/spotlight';
|
||||||
|
|
||||||
|
export const [searchSpotlightStore, searchSpotlight] = createSpotlight();
|
||||||
|
|
||||||
|
export const [shareSearchSpotlightStore, shareSearchSpotlight] =
|
||||||
|
createSpotlight();
|
||||||
|
|
||||||
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
86
apps/client/src/features/search/share-search-spotlight.tsx
Normal file
86
apps/client/src/features/search/share-search-spotlight.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -35,4 +35,5 @@ export interface ISuggestionResult {
|
|||||||
export interface IPageSearchParams {
|
export interface IPageSearchParams {
|
||||||
query: string;
|
query: string;
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
|
shareId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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, ' ')
|
||||||
|
|||||||
Reference in New Issue
Block a user