mirror of
https://github.com/docmost/docmost.git
synced 2026-06-22 09:01:37 +10:00
33895b0607
* util * fix page position collation * support fixed toolbar in templates editor * date localization * fix clipped emoji in templates editor * fix page updated time object * fix flickers * fix: remove redundant breadcrumb from destination modal
227 lines
7.1 KiB
TypeScript
227 lines
7.1 KiB
TypeScript
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
|
import { ActionIcon, TextInput, ScrollArea, Loader } from "@mantine/core";
|
|
import { useDebouncedValue } from "@mantine/hooks";
|
|
import { IconSearch, IconFileDescription } from "@tabler/icons-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
|
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query";
|
|
import { ISpace } from "@/features/space/types/space.types";
|
|
import { IPage } from "@/features/page/types/page.types";
|
|
import { DestinationSelection } from "./destination-picker.types";
|
|
import { SpaceRow } from "./space-row";
|
|
import classes from "./destination-picker.module.css";
|
|
|
|
type DestinationPickerProps = {
|
|
onSelectionChange: (selection: DestinationSelection | null) => void;
|
|
excludePageId?: string;
|
|
pageLimit?: number;
|
|
initialSpaceId?: string;
|
|
searchSpacesOnly?: boolean;
|
|
};
|
|
|
|
export function DestinationPicker({
|
|
onSelectionChange,
|
|
excludePageId,
|
|
pageLimit = 15,
|
|
initialSpaceId,
|
|
searchSpacesOnly,
|
|
}: DestinationPickerProps) {
|
|
const { t } = useTranslation();
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [selection, setSelection] = useState<DestinationSelection | null>(null);
|
|
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
|
|
const viewportRef = useRef<HTMLDivElement>(null);
|
|
|
|
const { data: spacesData, isLoading: spacesLoading } = useGetSpacesQuery({
|
|
limit: 100,
|
|
});
|
|
|
|
const searchEnabled =
|
|
!searchSpacesOnly && debouncedQuery && debouncedQuery.length >= 2;
|
|
|
|
const { data: searchData, isLoading: searchLoading } =
|
|
useSearchSuggestionsQuery({
|
|
query: searchEnabled ? debouncedQuery : "",
|
|
includePages: true,
|
|
limit: 20,
|
|
});
|
|
|
|
const isSearching = !!searchEnabled;
|
|
|
|
const filteredSpaces = useMemo(() => {
|
|
const items = spacesData?.items ?? [];
|
|
if (!searchSpacesOnly || !debouncedQuery) return items;
|
|
const fold = (s: string) =>
|
|
s
|
|
.normalize("NFD")
|
|
.replace(/[̀-ͯ]/g, "")
|
|
.toLocaleLowerCase();
|
|
const term = fold(debouncedQuery);
|
|
return items.filter((s) => fold(s.name).includes(term));
|
|
}, [spacesData, searchSpacesOnly, debouncedQuery]);
|
|
|
|
const selectedId =
|
|
selection?.type === "space" ? selection.spaceId : selection?.pageId ?? null;
|
|
|
|
const updateSelection = useCallback(
|
|
(next: DestinationSelection | null) => {
|
|
setSelection(next);
|
|
onSelectionChange(next);
|
|
},
|
|
[onSelectionChange],
|
|
);
|
|
|
|
const handleSearchResultClick = (page: Partial<IPage>) => {
|
|
if (!page.space || !page.id) return;
|
|
|
|
updateSelection({
|
|
type: "page",
|
|
spaceId: page.space.id,
|
|
pageId: page.id,
|
|
page,
|
|
space: page.space,
|
|
});
|
|
setSearchQuery("");
|
|
};
|
|
|
|
const handleSelectSpace = useCallback(
|
|
(space: ISpace) => {
|
|
updateSelection({ type: "space", spaceId: space.id, space });
|
|
},
|
|
[updateSelection],
|
|
);
|
|
|
|
const handleSelectPage = useCallback(
|
|
(page: Partial<IPage>, space: ISpace) => {
|
|
if (!page.id) return;
|
|
updateSelection({
|
|
type: "page",
|
|
spaceId: page.spaceId ?? space.id,
|
|
pageId: page.id,
|
|
page,
|
|
space,
|
|
});
|
|
},
|
|
[updateSelection],
|
|
);
|
|
|
|
// Pre-select space when initialSpaceId is set and spaces have loaded.
|
|
// Only runs once: skip if user has already made a selection.
|
|
useEffect(() => {
|
|
if (!initialSpaceId || selection) return;
|
|
const match = spacesData?.items?.find((s) => s.id === initialSpaceId);
|
|
if (match) {
|
|
updateSelection({ type: "space", spaceId: match.id, space: match });
|
|
requestAnimationFrame(() => {
|
|
const el = viewportRef.current?.querySelector<HTMLElement>(
|
|
`[data-space-id="${match.id}"]`,
|
|
);
|
|
el?.scrollIntoView({ block: "nearest" });
|
|
});
|
|
}
|
|
}, [initialSpaceId, selection, spacesData, updateSelection]);
|
|
|
|
return (
|
|
<>
|
|
<TextInput
|
|
leftSection={<IconSearch size={16} />}
|
|
placeholder={
|
|
searchSpacesOnly
|
|
? t("Search spaces...")
|
|
: t("Search pages and spaces...")
|
|
}
|
|
aria-label={
|
|
searchSpacesOnly
|
|
? t("Search spaces...")
|
|
: t("Search pages and spaces...")
|
|
}
|
|
variant="filled"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
|
className={classes.searchInput}
|
|
/>
|
|
|
|
<ScrollArea
|
|
h="50vh"
|
|
offsetScrollbars
|
|
className={classes.scrollArea}
|
|
viewportRef={viewportRef}
|
|
>
|
|
{isSearching ? (
|
|
searchLoading ? (
|
|
<div className={classes.emptyState}>
|
|
<Loader size="xs" />
|
|
</div>
|
|
) : searchData?.pages && searchData.pages.length > 0 ? (
|
|
searchData.pages.map(
|
|
(page) =>
|
|
page && (
|
|
<div
|
|
key={page.id}
|
|
className={classes.searchResult}
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => handleSearchResultClick(page)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
handleSearchResultClick(page);
|
|
}
|
|
}}
|
|
>
|
|
<div className={classes.iconWrapper}>
|
|
{page.icon ? (
|
|
page.icon
|
|
) : (
|
|
<ActionIcon
|
|
component="div"
|
|
variant="transparent"
|
|
c="gray"
|
|
size={22}
|
|
>
|
|
<IconFileDescription size={18} />
|
|
</ActionIcon>
|
|
)}
|
|
</div>
|
|
<div className={classes.pageTitle}>
|
|
{page.title || t("Untitled")}
|
|
</div>
|
|
{page.space && (
|
|
<div className={classes.spaceName}>
|
|
{page.space.name}
|
|
</div>
|
|
)}
|
|
</div>
|
|
),
|
|
)
|
|
) : (
|
|
<div className={classes.emptyState}>{t("No results found")}</div>
|
|
)
|
|
) : spacesLoading ? (
|
|
<div className={classes.emptyState}>
|
|
<Loader size="xs" />
|
|
</div>
|
|
) : filteredSpaces.length === 0 ? (
|
|
<div className={classes.emptyState}>
|
|
{searchSpacesOnly && debouncedQuery
|
|
? t("No spaces found")
|
|
: t("No results found")}
|
|
</div>
|
|
) : (
|
|
filteredSpaces.map((space) => (
|
|
<SpaceRow
|
|
key={space.id}
|
|
space={space}
|
|
limit={pageLimit}
|
|
selectedId={selectedId}
|
|
excludePageId={excludePageId}
|
|
onSelectSpace={handleSelectSpace}
|
|
onSelectPage={handleSelectPage}
|
|
/>
|
|
))
|
|
)}
|
|
</ScrollArea>
|
|
</>
|
|
);
|
|
}
|