mirror of
https://github.com/docmost/docmost.git
synced 2026-08-25 15:57:11 +08:00
* 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>
158 lines
4.5 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
}
|