mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 15:42:40 +10:00
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
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 {
|
||||
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<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 { 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) => (
|
||||
<Spotlight.Action
|
||||
key={page.id}
|
||||
onClick={() =>
|
||||
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" }}
|
||||
>
|
||||
<Group wrap="nowrap" w="100%">
|
||||
<Center>{getPageIcon(page?.icon)}</Center>
|
||||
@ -54,6 +54,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
return (
|
||||
<>
|
||||
<Spotlight.Root
|
||||
store={searchSpotlightStore}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
scrollable
|
||||
|
||||
@ -19,3 +19,10 @@ export async function searchSuggestions(
|
||||
const req = await api.post<ISuggestionResult>("/search/suggest", params);
|
||||
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 {
|
||||
query: string;
|
||||
spaceId?: string;
|
||||
shareId?: string;
|
||||
}
|
||||
|
||||
@ -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<HTMLElement | null>(null);
|
||||
|
||||
useClickOutside(
|
||||
() => {
|
||||
if (mobileOpened) {
|
||||
toggleMobile();
|
||||
}
|
||||
},
|
||||
null,
|
||||
[navbarOutside],
|
||||
);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 48 }}
|
||||
header={{ height: 50 }}
|
||||
{...(data?.pageTree?.length > 1 && {
|
||||
navbar: {
|
||||
width: 300,
|
||||
@ -91,7 +84,7 @@ export default function ShareShell({
|
||||
>
|
||||
<AppShell.Header>
|
||||
<Group wrap="nowrap" justify="space-between" py="sm" px="xl">
|
||||
<Group>
|
||||
<Group wrap="nowrap">
|
||||
{data?.pageTree?.length > 1 && (
|
||||
<>
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
@ -116,8 +109,21 @@ export default function ShareShell({
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{shareId && (
|
||||
<Group visibleFrom="sm">
|
||||
<SearchControl onClick={shareSearchSpotlight.open} />
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group>
|
||||
<>
|
||||
{shareId && (
|
||||
<Group hiddenFrom="sm">
|
||||
<SearchMobileControl onSearch={shareSearchSpotlight.open} />
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Tooltip label={t("Table of contents")} withArrow>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
@ -149,11 +155,7 @@ export default function ShareShell({
|
||||
</AppShell.Header>
|
||||
|
||||
{data?.pageTree?.length > 1 && (
|
||||
<AppShell.Navbar
|
||||
p="md"
|
||||
className={classes.navbar}
|
||||
ref={setNavbarOutside}
|
||||
>
|
||||
<AppShell.Navbar p="md" className={classes.navbar}>
|
||||
<MemoizedSharedTree sharedPageTree={data} />
|
||||
</AppShell.Navbar>
|
||||
)}
|
||||
@ -186,6 +188,8 @@ export default function ShareShell({
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</AppShell.Aside>
|
||||
|
||||
<ShareSearchSpotlight shareId={shareId} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<any>) {
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</Box>
|
||||
</>
|
||||
|
||||
@ -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<IPage[]>): SharedPageTreeNode[] {
|
||||
export function buildSharedPageTree(
|
||||
pages: Partial<IPage[]>,
|
||||
): SharedPageTreeNode[] {
|
||||
const pageMap: Record<string, SharedPageTreeNode> = {};
|
||||
|
||||
// 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,
|
||||
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<IPage[]>): 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);
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
|
||||
<UnstyledButton className={classes.menu} onClick={spotlight.open}>
|
||||
<UnstyledButton
|
||||
className={classes.menu}
|
||||
onClick={searchSpotlight.open}
|
||||
>
|
||||
<div className={classes.menuItemInner}>
|
||||
<IconSearch
|
||||
size={18}
|
||||
|
||||
Reference in New Issue
Block a user