mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 09:32:40 +10:00
feat(EE): full-text search in attachments (#1502)
* feat(EE): fulltext search in attachments * feat: global search - search filters - attachments search ui - and more * fix import * fix import * rename migration * add GIN index * fix table name * sanitize
This commit is contained in:
@ -14,6 +14,14 @@ import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useTrial from "@/ee/hooks/use-trial.tsx";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import {
|
||||
SearchControl,
|
||||
SearchMobileControl,
|
||||
} from "@/features/search/components/search-control.tsx";
|
||||
import {
|
||||
searchSpotlight,
|
||||
shareSearchSpotlight,
|
||||
} from "@/features/search/constants.ts";
|
||||
|
||||
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
|
||||
|
||||
@ -79,6 +87,15 @@ export function AppHeader() {
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
<Group visibleFrom="sm">
|
||||
<SearchControl onClick={searchSpotlight.open} />
|
||||
</Group>
|
||||
<Group hiddenFrom="sm">
|
||||
<SearchMobileControl onSearch={searchSpotlight.open} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<Group px={"xl"} wrap="nowrap">
|
||||
{isCloud() && isTrial && trialDaysLeft !== 0 && (
|
||||
<Badge
|
||||
|
||||
@ -1,16 +1,23 @@
|
||||
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Outlet, useParams } from "react-router-dom";
|
||||
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
||||
import { PosthogUser } from "@/ee/components/posthog-user.tsx";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
|
||||
import React from "react";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
|
||||
export default function Layout() {
|
||||
const { spaceSlug } = useParams();
|
||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||
|
||||
return (
|
||||
<UserProvider>
|
||||
<GlobalAppShell>
|
||||
<Outlet />
|
||||
</GlobalAppShell>
|
||||
{isCloud() && <PosthogUser />}
|
||||
<SearchSpotlight spaceId={space?.id} />
|
||||
</UserProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -50,6 +50,7 @@ import { extractPageSlugId } from "@/lib";
|
||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import { searchSpotlight } from '@/features/search/constants.ts';
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
@ -222,6 +223,10 @@ export default function PageEditor({
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyK') {
|
||||
searchSpotlight.open();
|
||||
return true;
|
||||
}
|
||||
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
|
||||
const slashCommand = document.querySelector("#slash-command");
|
||||
if (slashCommand) {
|
||||
|
||||
@ -26,6 +26,7 @@ import { UpdateEvent } from "@/features/websocket/types";
|
||||
import localEmitter from "@/lib/local-emitter.ts";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||
|
||||
export interface TitleEditorProps {
|
||||
pageId: string;
|
||||
@ -86,6 +87,20 @@ export function TitleEditor({
|
||||
content: title,
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
editorProps: {
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") {
|
||||
searchSpotlight.open();
|
||||
return true;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@ -193,7 +208,7 @@ export function TitleEditor({
|
||||
onKeyDown={(event) => {
|
||||
// First handle the search hotkey
|
||||
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
||||
|
||||
|
||||
// Then handle other key events
|
||||
handleTitleKeyDown(event);
|
||||
}}
|
||||
|
||||
@ -0,0 +1,124 @@
|
||||
import React from "react";
|
||||
import { Group, Center, Text, Badge, ActionIcon } from "@mantine/core";
|
||||
import { Spotlight } from "@mantine/spotlight";
|
||||
import { Link } from "react-router-dom";
|
||||
import { IconFile, IconDownload } from "@tabler/icons-react";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import { getPageIcon } from "@/lib";
|
||||
import {
|
||||
IAttachmentSearch,
|
||||
IPageSearch,
|
||||
} from "@/features/search/types/search.types";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
interface SearchResultItemProps {
|
||||
result: IPageSearch | IAttachmentSearch;
|
||||
isAttachmentResult: boolean;
|
||||
showSpace?: boolean;
|
||||
}
|
||||
|
||||
export function SearchResultItem({
|
||||
result,
|
||||
isAttachmentResult,
|
||||
showSpace,
|
||||
}: SearchResultItemProps) {
|
||||
if (isAttachmentResult) {
|
||||
const attachmentResult = result as IAttachmentSearch;
|
||||
|
||||
const handleDownload = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const downloadUrl = `/api/files/${attachmentResult.id}/${attachmentResult.fileName}`;
|
||||
window.open(downloadUrl, "_blank");
|
||||
};
|
||||
|
||||
return (
|
||||
<Spotlight.Action
|
||||
component={Link}
|
||||
//@ts-ignore
|
||||
to={buildPageUrl(
|
||||
attachmentResult.space.slug,
|
||||
attachmentResult.page.slugId,
|
||||
attachmentResult.page.title,
|
||||
)}
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
<Group wrap="nowrap" w="100%">
|
||||
<Center>
|
||||
<IconFile size={20} />
|
||||
</Center>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text>{attachmentResult.fileName}</Text>
|
||||
<Text size="xs" opacity={0.6}>
|
||||
{attachmentResult.space.name} • {attachmentResult.page.title}
|
||||
</Text>
|
||||
|
||||
{attachmentResult?.highlight && (
|
||||
<Text
|
||||
opacity={0.6}
|
||||
size="xs"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(attachmentResult.highlight, {
|
||||
ALLOWED_TAGS: ["mark", "em", "strong", "b"],
|
||||
ALLOWED_ATTR: []
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={handleDownload}
|
||||
title="Download attachment"
|
||||
>
|
||||
<IconDownload size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Spotlight.Action>
|
||||
);
|
||||
} else {
|
||||
const pageResult = result as IPageSearch;
|
||||
return (
|
||||
<Spotlight.Action
|
||||
component={Link}
|
||||
//@ts-ignore
|
||||
to={buildPageUrl(
|
||||
pageResult.space.slug,
|
||||
pageResult.slugId,
|
||||
pageResult.title,
|
||||
)}
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
<Group wrap="nowrap" w="100%">
|
||||
<Center>{getPageIcon(pageResult?.icon)}</Center>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text>{pageResult.title}</Text>
|
||||
|
||||
{showSpace && pageResult.space && (
|
||||
<Badge variant="light" size="xs" color="gray">
|
||||
{pageResult.space.name}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{pageResult?.highlight && (
|
||||
<Text
|
||||
opacity={0.6}
|
||||
size="xs"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(pageResult.highlight, {
|
||||
ALLOWED_TAGS: ["mark", "em", "strong", "b"],
|
||||
ALLOWED_ATTR: []
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
</Spotlight.Action>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
.filtersContainer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding: 8px 0;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.filterButton {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-6));
|
||||
&:hover {
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-6));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,252 @@
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
Text,
|
||||
TextInput,
|
||||
Divider,
|
||||
Badge,
|
||||
ScrollArea,
|
||||
Avatar,
|
||||
Group,
|
||||
getDefaultZIndex,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconBuilding,
|
||||
IconFileDescription,
|
||||
IconSearch,
|
||||
IconCheck,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||
import { useLicense } from "@/ee/hooks/use-license";
|
||||
import classes from "./search-spotlight-filters.module.css";
|
||||
|
||||
interface SearchSpotlightFiltersProps {
|
||||
onFiltersChange?: (filters: any) => void;
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
export function SearchSpotlightFilters({
|
||||
onFiltersChange,
|
||||
spaceId,
|
||||
}: SearchSpotlightFiltersProps) {
|
||||
const { t } = useTranslation();
|
||||
const { hasLicenseKey } = useLicense();
|
||||
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(
|
||||
spaceId || null,
|
||||
);
|
||||
const [spaceSearchQuery, setSpaceSearchQuery] = useState("");
|
||||
const [debouncedSpaceQuery] = useDebouncedValue(spaceSearchQuery, 300);
|
||||
const [contentType, setContentType] = useState<string | null>("page");
|
||||
|
||||
const { data: spacesData } = useGetSpacesQuery({
|
||||
page: 1,
|
||||
limit: 100,
|
||||
query: debouncedSpaceQuery,
|
||||
});
|
||||
|
||||
const selectedSpaceData = useMemo(() => {
|
||||
if (!spacesData?.items || !selectedSpaceId) return null;
|
||||
return spacesData.items.find((space) => space.id === selectedSpaceId);
|
||||
}, [spacesData?.items, selectedSpaceId]);
|
||||
|
||||
const availableSpaces = useMemo(() => {
|
||||
const spaces = spacesData?.items || [];
|
||||
if (!selectedSpaceId) return spaces;
|
||||
|
||||
// Sort to put selected space first
|
||||
return [...spaces].sort((a, b) => {
|
||||
if (a.id === selectedSpaceId) return -1;
|
||||
if (b.id === selectedSpaceId) return 1;
|
||||
return 0;
|
||||
});
|
||||
}, [spacesData?.items, selectedSpaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onFiltersChange) {
|
||||
onFiltersChange({
|
||||
spaceId: selectedSpaceId,
|
||||
contentType,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const contentTypeOptions = [
|
||||
{ value: "page", label: "Pages" },
|
||||
{
|
||||
value: "attachment",
|
||||
label: "Attachments",
|
||||
disabled: !hasLicenseKey,
|
||||
},
|
||||
];
|
||||
|
||||
const handleSpaceSelect = (spaceId: string | null) => {
|
||||
setSelectedSpaceId(spaceId);
|
||||
|
||||
if (onFiltersChange) {
|
||||
onFiltersChange({
|
||||
spaceId: spaceId,
|
||||
contentType,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterChange = (filterType: string, value: any) => {
|
||||
let newSelectedSpaceId = selectedSpaceId;
|
||||
let newContentType = contentType;
|
||||
|
||||
switch (filterType) {
|
||||
case "spaceId":
|
||||
newSelectedSpaceId = value;
|
||||
setSelectedSpaceId(value);
|
||||
break;
|
||||
case "contentType":
|
||||
newContentType = value;
|
||||
setContentType(value);
|
||||
break;
|
||||
}
|
||||
|
||||
if (onFiltersChange) {
|
||||
onFiltersChange({
|
||||
spaceId: newSelectedSpaceId,
|
||||
contentType: newContentType,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.filtersContainer}>
|
||||
<Menu
|
||||
shadow="md"
|
||||
width={250}
|
||||
position="bottom-start"
|
||||
zIndex={getDefaultZIndex("max")}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
rightSection={<IconChevronDown size={14} />}
|
||||
leftSection={<IconBuilding size={16} />}
|
||||
className={classes.filterButton}
|
||||
fw={500}
|
||||
>
|
||||
{selectedSpaceId
|
||||
? `Space: ${selectedSpaceData?.name || "Unknown"}`
|
||||
: "Space: All spaces"}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<TextInput
|
||||
placeholder="Find a space"
|
||||
data-autofocus
|
||||
autoFocus
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={spaceSearchQuery}
|
||||
onChange={(e) => setSpaceSearchQuery(e.target.value)}
|
||||
size="sm"
|
||||
variant="filled"
|
||||
radius="sm"
|
||||
styles={{ input: { marginBottom: 8 } }}
|
||||
/>
|
||||
|
||||
<ScrollArea.Autosize mah={280}>
|
||||
<Menu.Item onClick={() => handleSpaceSelect(null)}>
|
||||
<Group flex="1" gap="xs">
|
||||
<Avatar
|
||||
color="initials"
|
||||
variant="filled"
|
||||
name="All spaces"
|
||||
size={20}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
All spaces
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Search in all your spaces
|
||||
</Text>
|
||||
</div>
|
||||
{!selectedSpaceId && <IconCheck size={20} />}
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
|
||||
<Divider my="xs" />
|
||||
|
||||
{availableSpaces.map((space) => (
|
||||
<Menu.Item
|
||||
key={space.id}
|
||||
onClick={() => handleSpaceSelect(space.id)}
|
||||
>
|
||||
<Group flex="1" gap="xs">
|
||||
<Avatar
|
||||
color="initials"
|
||||
variant="filled"
|
||||
name={space.name}
|
||||
size={20}
|
||||
/>
|
||||
<Text size="sm" fw={500} style={{ flex: 1 }} truncate>
|
||||
{space.name}
|
||||
</Text>
|
||||
{selectedSpaceId === space.id && <IconCheck size={20} />}
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</ScrollArea.Autosize>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<Menu
|
||||
shadow="md"
|
||||
width={220}
|
||||
position="bottom-start"
|
||||
zIndex={getDefaultZIndex("max")}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
rightSection={<IconChevronDown size={14} />}
|
||||
leftSection={<IconFileDescription size={16} />}
|
||||
className={classes.filterButton}
|
||||
fw={500}
|
||||
>
|
||||
{contentType
|
||||
? `Type: ${contentTypeOptions.find((opt) => opt.value === contentType)?.label || contentType}`
|
||||
: "Type"}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{contentTypeOptions.map((option) => (
|
||||
<Menu.Item
|
||||
key={option.value}
|
||||
onClick={() =>
|
||||
!option.disabled &&
|
||||
contentType !== option.value &&
|
||||
handleFilterChange("contentType", option.value)
|
||||
}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
<Group flex="1" gap="xs">
|
||||
<div>
|
||||
<Text size="sm">{option.label}</Text>
|
||||
{option.disabled && (
|
||||
<Badge size="xs" mt={4}>
|
||||
Enterprise
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{contentType === option.value && <IconCheck size={20} />}
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
apps/client/src/features/search/components/search-spotlight.tsx
Normal file
104
apps/client/src/features/search/components/search-spotlight.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import { Spotlight } from "@mantine/spotlight";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { searchSpotlightStore } from "../constants.ts";
|
||||
import { SearchSpotlightFilters } from "./search-spotlight-filters.tsx";
|
||||
import { useUnifiedSearch } from "../hooks/use-unified-search.ts";
|
||||
import { SearchResultItem } from "./search-result-item.tsx";
|
||||
import { useLicense } from "@/ee/hooks/use-license.tsx";
|
||||
|
||||
interface SearchSpotlightProps {
|
||||
spaceId?: string;
|
||||
}
|
||||
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
const { t } = useTranslation();
|
||||
const { hasLicenseKey } = useLicense();
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
|
||||
const [filters, setFilters] = useState<{
|
||||
spaceId?: string | null;
|
||||
contentType?: string;
|
||||
}>({
|
||||
contentType: "page",
|
||||
});
|
||||
|
||||
// Build unified search params
|
||||
const searchParams = useMemo(() => {
|
||||
const params: any = {
|
||||
query: debouncedSearchQuery,
|
||||
contentType: filters.contentType || "page", // Only used for frontend routing
|
||||
};
|
||||
|
||||
// Handle space filtering - only pass spaceId if a specific space is selected
|
||||
if (filters.spaceId) {
|
||||
params.spaceId = filters.spaceId;
|
||||
}
|
||||
|
||||
return params;
|
||||
}, [debouncedSearchQuery, filters]);
|
||||
|
||||
const { data: searchResults, isLoading } = useUnifiedSearch(searchParams);
|
||||
|
||||
// Determine result type for rendering
|
||||
const isAttachmentSearch =
|
||||
filters.contentType === "attachment" && hasLicenseKey;
|
||||
|
||||
const resultItems = (searchResults || []).map((result) => (
|
||||
<SearchResultItem
|
||||
key={result.id}
|
||||
result={result}
|
||||
isAttachmentResult={isAttachmentSearch}
|
||||
showSpace={!filters.spaceId}
|
||||
/>
|
||||
));
|
||||
|
||||
const handleFiltersChange = (newFilters: any) => {
|
||||
setFilters(newFilters);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spotlight.Root
|
||||
size="xl"
|
||||
maxHeight={600}
|
||||
store={searchSpotlightStore}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
scrollable
|
||||
overlayProps={{
|
||||
backgroundOpacity: 0.55,
|
||||
}}
|
||||
>
|
||||
<Spotlight.Search
|
||||
placeholder={t("Search...")}
|
||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: "4px 16px",
|
||||
}}
|
||||
>
|
||||
<SearchSpotlightFilters
|
||||
onFiltersChange={handleFiltersChange}
|
||||
spaceId={spaceId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Spotlight.ActionsList>
|
||||
{query.length === 0 && resultItems.length === 0 && (
|
||||
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
|
||||
)}
|
||||
|
||||
{query.length > 0 && !isLoading && resultItems.length === 0 && (
|
||||
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
|
||||
)}
|
||||
|
||||
{resultItems.length > 0 && <>{resultItems}</>}
|
||||
</Spotlight.ActionsList>
|
||||
</Spotlight.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -9,6 +9,7 @@ import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { getPageIcon } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { shareSearchSpotlightStore } from "@/features/search/constants.ts";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
interface ShareSearchSpotlightProps {
|
||||
shareId?: string;
|
||||
@ -47,7 +48,12 @@ export function ShareSearchSpotlight({ shareId }: ShareSearchSpotlightProps) {
|
||||
<Text
|
||||
opacity={0.6}
|
||||
size="xs"
|
||||
dangerouslySetInnerHTML={{ __html: page.highlight }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(page.highlight, {
|
||||
ALLOWED_TAGS: ["mark", "em", "strong", "b"],
|
||||
ALLOWED_ATTR: []
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
42
apps/client/src/features/search/hooks/use-unified-search.ts
Normal file
42
apps/client/src/features/search/hooks/use-unified-search.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import {
|
||||
searchPage,
|
||||
searchAttachments,
|
||||
} from "@/features/search/services/search-service";
|
||||
import {
|
||||
IAttachmentSearch,
|
||||
IPageSearch,
|
||||
IPageSearchParams,
|
||||
} from "@/features/search/types/search.types";
|
||||
import { useLicense } from "@/ee/hooks/use-license";
|
||||
|
||||
export type UnifiedSearchResult = IPageSearch | IAttachmentSearch;
|
||||
|
||||
export interface UseUnifiedSearchParams extends IPageSearchParams {
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
export function useUnifiedSearch(
|
||||
params: UseUnifiedSearchParams,
|
||||
): UseQueryResult<UnifiedSearchResult[], Error> {
|
||||
const { hasLicenseKey } = useLicense();
|
||||
|
||||
const isAttachmentSearch =
|
||||
params.contentType === "attachment" && hasLicenseKey;
|
||||
const searchType = isAttachmentSearch ? "attachment" : "page";
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["unified-search", searchType, params],
|
||||
queryFn: async () => {
|
||||
// Remove contentType from backend params since it's only used for frontend routing
|
||||
const { contentType, ...backendParams } = params;
|
||||
|
||||
if (isAttachmentSearch) {
|
||||
return await searchAttachments(backendParams);
|
||||
} else {
|
||||
return await searchPage(backendParams);
|
||||
}
|
||||
},
|
||||
enabled: !!params.query,
|
||||
});
|
||||
}
|
||||
@ -1,15 +1,17 @@
|
||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import {
|
||||
searchAttachments,
|
||||
searchPage,
|
||||
searchShare,
|
||||
searchSuggestions,
|
||||
} from "@/features/search/services/search-service";
|
||||
} from '@/features/search/services/search-service';
|
||||
import {
|
||||
IAttachmentSearch,
|
||||
IPageSearch,
|
||||
IPageSearchParams,
|
||||
ISuggestionResult,
|
||||
SearchSuggestionParams,
|
||||
} from "@/features/search/types/search.types";
|
||||
} from '@/features/search/types/search.types';
|
||||
|
||||
export function usePageSearchQuery(
|
||||
params: IPageSearchParams,
|
||||
@ -41,3 +43,13 @@ export function useShareSearchQuery(
|
||||
enabled: !!params.query,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAttachmentSearchQuery(
|
||||
params: IPageSearchParams,
|
||||
): UseQueryResult<IAttachmentSearch[], Error> {
|
||||
return useQuery({
|
||||
queryKey: ["attachment-search", params],
|
||||
queryFn: () => searchAttachments(params),
|
||||
enabled: !!params.query,
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,83 +0,0 @@
|
||||
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 { 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 [query, setQuery] = useState("");
|
||||
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
|
||||
|
||||
const { data: searchResults } = usePageSearchQuery({
|
||||
query: debouncedSearchQuery,
|
||||
spaceId,
|
||||
});
|
||||
|
||||
const pages = (
|
||||
searchResults && searchResults.length > 0 ? searchResults : []
|
||||
).map((page) => (
|
||||
<Spotlight.Action
|
||||
key={page.id}
|
||||
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>
|
||||
|
||||
<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={searchSpotlightStore}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,11 @@
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
IAttachmentSearch,
|
||||
IPageSearch,
|
||||
IPageSearchParams,
|
||||
ISuggestionResult,
|
||||
SearchSuggestionParams,
|
||||
} from "@/features/search/types/search.types";
|
||||
} from '@/features/search/types/search.types';
|
||||
|
||||
export async function searchPage(
|
||||
params: IPageSearchParams,
|
||||
@ -26,3 +27,10 @@ export async function searchShare(
|
||||
const req = await api.post<IPageSearch[]>("/search/share-search", params);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function searchAttachments(
|
||||
params: IPageSearchParams,
|
||||
): Promise<IAttachmentSearch[]> {
|
||||
const req = await api.post<IAttachmentSearch[]>("/search-attachments", params);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@ -37,3 +37,25 @@ export interface IPageSearchParams {
|
||||
spaceId?: string;
|
||||
shareId?: string;
|
||||
}
|
||||
|
||||
export interface IAttachmentSearch {
|
||||
id: string;
|
||||
fileName: string;
|
||||
pageId: string;
|
||||
creatorId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
rank: string;
|
||||
highlight: string;
|
||||
space: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
icon: string;
|
||||
};
|
||||
page: {
|
||||
id: string;
|
||||
title: string;
|
||||
slugId: string;
|
||||
};
|
||||
}
|
||||
|
||||
@ -34,7 +34,7 @@ import {
|
||||
SearchControl,
|
||||
SearchMobileControl,
|
||||
} from "@/features/search/components/search-control.tsx";
|
||||
import { ShareSearchSpotlight } from "@/features/search/share-search-spotlight";
|
||||
import { ShareSearchSpotlight } from "@/features/search/components/share-search-spotlight.tsx";
|
||||
import { shareSearchSpotlight } from "@/features/search/constants";
|
||||
import ShareBranding from '@/features/share/components/share-branding.tsx';
|
||||
|
||||
|
||||
@ -19,7 +19,6 @@ import {
|
||||
import classes from "./space-sidebar.module.css";
|
||||
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";
|
||||
import { Link, useLocation, useParams } from "react-router-dom";
|
||||
import clsx from "clsx";
|
||||
@ -195,8 +194,6 @@ export function SpaceSidebar() {
|
||||
onClose={closeSettings}
|
||||
spaceId={space?.slug}
|
||||
/>
|
||||
|
||||
<SearchSpotlight spaceId={space.id} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
const APP_ROUTE = {
|
||||
HOME: "/home",
|
||||
SPACES: "/spaces",
|
||||
SEARCH: "/search",
|
||||
AUTH: {
|
||||
LOGIN: "/login",
|
||||
SIGNUP: "/signup",
|
||||
|
||||
Reference in New Issue
Block a user