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:
Philip Okugbe
2025-09-02 05:27:01 +01:00
committed by GitHub
parent dcbb65d799
commit f12866cf42
29 changed files with 956 additions and 109 deletions

View File

@ -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

View File

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

View File

@ -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) {

View File

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

View File

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

View File

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

View File

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

View 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>
</>
);
}

View File

@ -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>

View 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,
});
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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';

View File

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

View File

@ -1,6 +1,7 @@
const APP_ROUTE = {
HOME: "/home",
SPACES: "/spaces",
SEARCH: "/search",
AUTH: {
LOGIN: "/login",
SIGNUP: "/signup",