mirror of
https://github.com/docmost/docmost.git
synced 2025-11-14 06:41:08 +10:00
Compare commits
7 Commits
e2b8899569
...
enhance-sh
| Author | SHA1 | Date | |
|---|---|---|---|
| 96b8345cef | |||
| 8ea805ee3d | |||
| b9e2e5fd6b | |||
| 505334820f | |||
| ba3d6a37cf | |||
| 69886cd9fb | |||
| e99f32ae9b |
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -2,36 +2,36 @@ import { Group, Center, Text } from "@mantine/core";
|
|||||||
import { Spotlight } from "@mantine/spotlight";
|
import { Spotlight } from "@mantine/spotlight";
|
||||||
import { IconSearch } from "@tabler/icons-react";
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { usePageSearchQuery } from "@/features/search/queries/search-query";
|
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;
|
||||||
}
|
}
|
||||||
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
|
||||||
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 : []
|
||||||
).map((page) => (
|
).map((page) => (
|
||||||
<Spotlight.Action
|
<Spotlight.Action
|
||||||
key={page.id}
|
key={page.id}
|
||||||
onClick={() =>
|
component={Link}
|
||||||
navigate(buildPageUrl(page.space.slug, page.slugId, page.title))
|
//@ts-ignore
|
||||||
}
|
to={buildPageUrl(page.space.slug, page.slugId, page.title)}
|
||||||
|
style={{ userSelect: "none" }}
|
||||||
>
|
>
|
||||||
<Group wrap="nowrap" w="100%">
|
<Group wrap="nowrap" w="100%">
|
||||||
<Center>{getPageIcon(page?.icon)}</Center>
|
<Center>{getPageIcon(page?.icon)}</Center>
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
87
apps/client/src/features/search/share-search-spotlight.tsx
Normal file
87
apps/client/src/features/search/share-search-spotlight.tsx
Normal file
@ -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) => (
|
||||||
|
<Spotlight.Action
|
||||||
|
key={page.id}
|
||||||
|
component={Link}
|
||||||
|
//@ts-ignore
|
||||||
|
to={buildSharedPageUrl({
|
||||||
|
shareId: shareId,
|
||||||
|
pageTitle: page.title,
|
||||||
|
pageSlugId: page.slugId,
|
||||||
|
})}
|
||||||
|
style={{ userSelect: "none" }}
|
||||||
|
>
|
||||||
|
<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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Affix,
|
Affix,
|
||||||
@ -30,7 +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 { 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);
|
const MemoizedSharedTree = React.memo(SharedTree);
|
||||||
|
|
||||||
@ -54,21 +59,9 @@ export default function ShareShell({
|
|||||||
const { data } = useGetSharedPageTreeQuery(shareId);
|
const { data } = useGetSharedPageTreeQuery(shareId);
|
||||||
const readOnlyEditor = useAtomValue(readOnlyEditorAtom);
|
const readOnlyEditor = useAtomValue(readOnlyEditorAtom);
|
||||||
|
|
||||||
const [navbarOutside, setNavbarOutside] = useState<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
useClickOutside(
|
|
||||||
() => {
|
|
||||||
if (mobileOpened) {
|
|
||||||
toggleMobile();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
[navbarOutside],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
header={{ height: 48 }}
|
header={{ height: 50 }}
|
||||||
{...(data?.pageTree?.length > 1 && {
|
{...(data?.pageTree?.length > 1 && {
|
||||||
navbar: {
|
navbar: {
|
||||||
width: 300,
|
width: 300,
|
||||||
@ -91,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")}>
|
||||||
@ -116,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"
|
||||||
@ -149,11 +155,7 @@ export default function ShareShell({
|
|||||||
</AppShell.Header>
|
</AppShell.Header>
|
||||||
|
|
||||||
{data?.pageTree?.length > 1 && (
|
{data?.pageTree?.length > 1 && (
|
||||||
<AppShell.Navbar
|
<AppShell.Navbar p="md" className={classes.navbar}>
|
||||||
p="md"
|
|
||||||
className={classes.navbar}
|
|
||||||
ref={setNavbarOutside}
|
|
||||||
>
|
|
||||||
<MemoizedSharedTree sharedPageTree={data} />
|
<MemoizedSharedTree sharedPageTree={data} />
|
||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
)}
|
)}
|
||||||
@ -186,6 +188,8 @@ export default function ShareShell({
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</AppShell.Aside>
|
</AppShell.Aside>
|
||||||
|
|
||||||
|
<ShareSearchSpotlight shareId={shareId} />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import clsx from "clsx";
|
|||||||
import {
|
import {
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
|
IconFileDescription,
|
||||||
IconPointFilled,
|
IconPointFilled,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { ActionIcon, Box } from "@mantine/core";
|
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 classes from "@/features/page/tree/styles/tree.module.css";
|
||||||
import styles from "./share.module.css";
|
import styles from "./share.module.css";
|
||||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
|
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
||||||
|
|
||||||
interface SharedTree {
|
interface SharedTree {
|
||||||
sharedPageTree: ISharedPageTree;
|
sharedPageTree: ISharedPageTree;
|
||||||
@ -141,6 +143,20 @@ function Node({ node, style, tree }: NodeRendererProps<any>) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PageArrow node={node} />
|
<PageArrow node={node} />
|
||||||
|
<div style={{ marginRight: "4px" }}>
|
||||||
|
<EmojiPicker
|
||||||
|
onEmojiSelect={() => {}}
|
||||||
|
icon={
|
||||||
|
node.data.icon ? (
|
||||||
|
node.data.icon
|
||||||
|
) : (
|
||||||
|
<IconFileDescription size="18" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
readOnly={true}
|
||||||
|
removeEmojiAction={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<span className={classes.text}>{node.data.name || t("untitled")}</span>
|
<span className={classes.text}>{node.data.name || t("untitled")}</span>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -11,11 +11,13 @@ export type SharedPageTreeNode = {
|
|||||||
parentPageId: string;
|
parentPageId: string;
|
||||||
hasChildren: boolean;
|
hasChildren: boolean;
|
||||||
children: SharedPageTreeNode[];
|
children: SharedPageTreeNode[];
|
||||||
label: string,
|
label: string;
|
||||||
value: string,
|
value: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildSharedPageTree(pages: Partial<IPage[]>): SharedPageTreeNode[] {
|
export function buildSharedPageTree(
|
||||||
|
pages: Partial<IPage[]>,
|
||||||
|
): SharedPageTreeNode[] {
|
||||||
const pageMap: Record<string, SharedPageTreeNode> = {};
|
const pageMap: Record<string, SharedPageTreeNode> = {};
|
||||||
|
|
||||||
// Initialize each page as a tree node and store it in a map.
|
// Initialize each page as a tree node and store it in a map.
|
||||||
@ -30,7 +32,7 @@ export function buildSharedPageTree(pages: Partial<IPage[]>): SharedPageTreeNode
|
|||||||
hasChildren: false,
|
hasChildren: false,
|
||||||
spaceId: page.spaceId,
|
spaceId: page.spaceId,
|
||||||
parentPageId: page.parentPageId,
|
parentPageId: page.parentPageId,
|
||||||
label: page.title || 'untitled',
|
label: page.title || "untitled",
|
||||||
value: page.id,
|
value: page.id,
|
||||||
children: [],
|
children: [],
|
||||||
};
|
};
|
||||||
@ -55,6 +57,12 @@ export function buildSharedPageTree(pages: Partial<IPage[]>): SharedPageTreeNode
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return the sorted tree.
|
function sortTree(nodes: SharedPageTreeNode[]): SharedPageTreeNode[] {
|
||||||
return sortPositionKeys(tree);
|
return sortPositionKeys(nodes).map((node: SharedPageTreeNode) => ({
|
||||||
|
...node,
|
||||||
|
children: sortTree(node.children),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortTree(tree);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Node } from '@tiptap/pm/model';
|
import { Node } from '@tiptap/pm/model';
|
||||||
import { jsonToNode } from '../../../collaboration/collaboration.util';
|
import { jsonToNode } from '../../../collaboration/collaboration.util';
|
||||||
import { validate as isValidUUID } from 'uuid';
|
import { validate as isValidUUID } from 'uuid';
|
||||||
|
import { Transform } from '@tiptap/pm/transform';
|
||||||
|
|
||||||
export interface MentionNode {
|
export interface MentionNode {
|
||||||
id: string;
|
id: string;
|
||||||
@ -95,3 +96,15 @@ export function getAttachmentIds(prosemirrorJson: any) {
|
|||||||
|
|
||||||
return attachmentIds;
|
return attachmentIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function removeMarkTypeFromDoc(doc: Node, markName: string): Node {
|
||||||
|
const { schema } = doc.type;
|
||||||
|
const markType = schema.marks[markName];
|
||||||
|
|
||||||
|
if (!markType) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tr = new Transform(doc).removeMark(0, doc.content.size, markType);
|
||||||
|
return tr.doc;
|
||||||
|
}
|
||||||
@ -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, ' ')
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
getAttachmentIds,
|
getAttachmentIds,
|
||||||
getProsemirrorContent,
|
getProsemirrorContent,
|
||||||
isAttachmentNode,
|
isAttachmentNode,
|
||||||
|
removeMarkTypeFromDoc,
|
||||||
} from '../../common/helpers/prosemirror/utils';
|
} from '../../common/helpers/prosemirror/utils';
|
||||||
import { Node } from '@tiptap/pm/model';
|
import { Node } from '@tiptap/pm/model';
|
||||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||||
@ -223,11 +224,7 @@ export class ShareService {
|
|||||||
.end()
|
.end()
|
||||||
.as('found'),
|
.as('found'),
|
||||||
])
|
])
|
||||||
.where(
|
.where(isValidUUID(childPageId) ? 'id' : 'slugId', '=', childPageId)
|
||||||
isValidUUID(childPageId) ? 'id' : 'slugId',
|
|
||||||
'=',
|
|
||||||
childPageId,
|
|
||||||
)
|
|
||||||
.unionAll((exp) =>
|
.unionAll((exp) =>
|
||||||
exp
|
exp
|
||||||
.selectFrom('pages as p')
|
.selectFrom('pages as p')
|
||||||
@ -292,6 +289,7 @@ export class ShareService {
|
|||||||
updateAttachmentAttr(node, 'url', token);
|
updateAttachmentAttr(node, 'url', token);
|
||||||
});
|
});
|
||||||
|
|
||||||
return doc.toJSON();
|
const removeCommentMarks = removeMarkTypeFromDoc(doc, 'comment');
|
||||||
|
return removeCommentMarks.toJSON();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user