Files
docmost/apps/client/src/features/search/components/search-result-item.tsx
T
SalihuandPhilipinho 917195b2f2 feat: enhanced search filters (#2398)
* advanced search filters

* improve labelId filtering in query

* index label ids on typesense

* index label ids on typesense

* filters on AI search

* refactor: detect typesense schema drift from the declared collection schema

* feat: show secondary search filters only when applied

Created by and Labels now stay hidden until they hold a value; a Filter
button at the right end of the bar lists the hidden ones and opens the
picked filter's dropdown immediately.

* fix: keep applied search filters in the order they were added

* feat: add title-only search mode

Adds a titleOnly flag to page search: the Postgres driver matches
titles via trigram-indexed unaccented LIKE ranked by word_similarity,
and a Match filter in the search spotlight switches between everything
and title-only matching.

* refactor: make title-only search a toggle chip

* fix: drop stale results when the search content type changes

* feat: add trigram index on attachment file names

* feat: extend title-only search to attachments

The titleOnly flag now matches attachment file names via the
separator-normalized trigram expression, and the toggle chip stays
available in attachment mode as File name only.

* refactor: keep the title-only label uniform across search modes

* feat: index txt attachments for content search

* feat: pin the current user at the top of the creator filter

* refactor: dim the you suffix in the creator filter

* feat: browse label-filtered pages without a query

Selecting labels now lists their pages newest-first before any text is
typed, on both the Postgres and Typesense drivers.

* fix: match any selected label instead of requiring all

* fix: use a gray spotlight selection instead of primary blue

* feat: include search results in the spotlight tab order

* feat: show last updated time on search results

* fix: stop empty-state flicker while the search query debounces

* feat: browse creator-filtered results without a query

Selecting a creator with an empty query now lists their pages (or
attachments in attachment mode) newest-first, with the same space and
page permission filtering as typed search.

* fix: show Untitled for pages without a title in search results

* quick page sharing fix

* minor search filter fixes

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2026-08-25 01:39:33 +01:00

158 lines
4.5 KiB
TypeScript

import React from "react";
import {
Group,
Center,
Text,
Badge,
ActionIcon,
Tooltip,
getDefaultZIndex,
} 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";
import { useTranslation } from "react-i18next";
import { timeAgo } from "@/lib/time.ts";
interface SearchResultItemProps {
result: IPageSearch | IAttachmentSearch;
isAttachmentResult: boolean;
showSpace?: boolean;
}
// Spotlight hardcodes tabIndex={-1} after spreading props; a ref wins and
// React never writes -1 back because the prop value never changes
const makeActionTabbable = (el: HTMLElement | null) => {
if (el) {
el.tabIndex = 0;
}
};
export function SearchResultItem({
result,
isAttachmentResult,
showSpace,
}: SearchResultItemProps) {
const { t } = useTranslation();
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}
ref={makeActionTabbable}
//@ts-ignore
to={buildPageUrl(
attachmentResult.space.slug,
attachmentResult.page.slugId,
attachmentResult.page.title,
)}
style={{ userSelect: "none" }}
>
<Group wrap="nowrap" w="100%">
<Center>
<IconFile size={16} />
</Center>
<div style={{ flex: 1, minWidth: 0 }}>
<Group justify="space-between" wrap="nowrap" gap="xs">
<Text truncate>{attachmentResult.fileName}</Text>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{timeAgo(attachmentResult.updatedAt)}
</Text>
</Group>
<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>
<Tooltip
label={t("Download attachment")}
zIndex={getDefaultZIndex("max")}
withArrow
>
<ActionIcon variant="subtle" color="gray" onClick={handleDownload}>
<IconDownload size={18} />
</ActionIcon>
</Tooltip>
</Group>
</Spotlight.Action>
);
} else {
const pageResult = result as IPageSearch;
return (
<Spotlight.Action
component={Link}
ref={makeActionTabbable}
//@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, minWidth: 0 }}>
<Group justify="space-between" wrap="nowrap" gap="xs">
<Text truncate>{pageResult.title || t("Untitled")}</Text>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{timeAgo(pageResult.updatedAt)}
</Text>
</Group>
{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>
);
}
}