mirror of
https://github.com/docmost/docmost.git
synced 2026-09-02 11:55:35 +08:00
269 lines
8.0 KiB
TypeScript
269 lines
8.0 KiB
TypeScript
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
|
import { Popover, ActionIcon, Text } from "@mantine/core";
|
|
import { useDebouncedValue } from "@mantine/hooks";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { IconX, IconFileDescription } from "@tabler/icons-react";
|
|
import { Link } from "react-router-dom";
|
|
import clsx from "clsx";
|
|
import { IBaseProperty } from "@/features/base/types/base.types";
|
|
import { useResolvedPages } from "@/features/base/queries/base-page-resolver-query";
|
|
import { useBaseQuery } from "@/features/base/queries/base-query";
|
|
import { searchSuggestions } from "@/features/search/services/search-service";
|
|
import { buildPageUrl } from "@/features/page/page.utils";
|
|
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
|
|
import cellClasses from "@/features/base/styles/cells.module.css";
|
|
|
|
type CellPageProps = {
|
|
value: unknown;
|
|
property: IBaseProperty;
|
|
rowId: string;
|
|
isEditing: boolean;
|
|
onCommit: (value: unknown) => void;
|
|
onCancel: () => void;
|
|
};
|
|
|
|
type PageSuggestion = {
|
|
id: string;
|
|
slugId: string;
|
|
title: string | null;
|
|
icon: string | null;
|
|
spaceId: string;
|
|
space?: { id: string; slug: string; name: string } | null;
|
|
};
|
|
|
|
function parsePageId(value: unknown): string | null {
|
|
if (typeof value === "string" && value.length > 0) return value;
|
|
return null;
|
|
}
|
|
|
|
export function CellPage({
|
|
value,
|
|
property,
|
|
isEditing,
|
|
onCommit,
|
|
onCancel,
|
|
}: CellPageProps) {
|
|
const pageId = parsePageId(value);
|
|
const { data: base } = useBaseQuery(property.pageId);
|
|
|
|
const ids = useMemo(() => (pageId ? [pageId] : []), [pageId]);
|
|
const { pages } = useResolvedPages(ids);
|
|
const resolvedPage = pageId ? pages.get(pageId) : undefined;
|
|
|
|
if (isEditing) {
|
|
return (
|
|
<PagePicker
|
|
pageId={pageId}
|
|
resolvedPage={resolvedPage ?? null}
|
|
spaceId={base?.spaceId}
|
|
onCommit={onCommit}
|
|
onCancel={onCancel}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (!pageId) {
|
|
return <span className={cellClasses.emptyValue} />;
|
|
}
|
|
|
|
if (resolvedPage === undefined) {
|
|
// Still resolving — render an empty pill-shaped placeholder to avoid
|
|
// the "Page not found" flicker on initial load.
|
|
return <span className={cellClasses.emptyValue} />;
|
|
}
|
|
|
|
if (resolvedPage === null) {
|
|
return (
|
|
<span className={cellClasses.pageMissing}>
|
|
<IconFileDescription size={14} />
|
|
<span>Page not found</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return <PagePill page={resolvedPage} />;
|
|
}
|
|
|
|
type PillPage = {
|
|
slugId: string;
|
|
title: string | null;
|
|
icon: string | null;
|
|
space: { slug: string } | null;
|
|
};
|
|
|
|
function PagePill({ page }: { page: PillPage }) {
|
|
const title = page.title || "Untitled";
|
|
const spaceSlug = page.space?.slug ?? "";
|
|
const url = buildPageUrl(spaceSlug, page.slugId, title);
|
|
|
|
return (
|
|
<Link
|
|
to={url}
|
|
className={cellClasses.pagePill}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onDoubleClick={(e) => e.stopPropagation()}
|
|
>
|
|
{page.icon ? (
|
|
<span className={cellClasses.pagePillIcon}>{page.icon}</span>
|
|
) : (
|
|
<IconFileDescription size={14} className={cellClasses.pagePillIconFallback} />
|
|
)}
|
|
<span className={cellClasses.pagePillText}>{title}</span>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
type PagePickerProps = {
|
|
pageId: string | null;
|
|
resolvedPage: { id: string; slugId: string; title: string | null; icon: string | null; space: { id: string; slug: string; name: string } | null } | null;
|
|
spaceId?: string;
|
|
onCommit: (value: unknown) => void;
|
|
onCancel: () => void;
|
|
};
|
|
|
|
function PagePicker({
|
|
pageId,
|
|
resolvedPage,
|
|
spaceId,
|
|
onCommit,
|
|
onCancel,
|
|
}: PagePickerProps) {
|
|
const [search, setSearch] = useState("");
|
|
const [debouncedSearch] = useDebouncedValue(search, 250);
|
|
const searchRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
requestAnimationFrame(() => searchRef.current?.focus());
|
|
}, []);
|
|
|
|
const trimmed = debouncedSearch.trim();
|
|
const { data: suggestions = [] } = useQuery({
|
|
queryKey: ["bases", "pages", "search", trimmed, spaceId ?? ""],
|
|
queryFn: async () => {
|
|
const res = await searchSuggestions({
|
|
query: trimmed,
|
|
includePages: true,
|
|
spaceId,
|
|
limit: trimmed ? 25 : 5,
|
|
});
|
|
return (res.pages ?? []) as PageSuggestion[];
|
|
},
|
|
staleTime: 15_000,
|
|
});
|
|
|
|
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
|
|
useListKeyboardNav(suggestions.length, [debouncedSearch]);
|
|
|
|
const handleSelect = useCallback(
|
|
(id: string) => {
|
|
onCommit(id === pageId ? null : id);
|
|
},
|
|
[pageId, onCommit],
|
|
);
|
|
|
|
const handleRemove = useCallback(() => {
|
|
onCommit(null);
|
|
}, [onCommit]);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
onCancel();
|
|
return;
|
|
}
|
|
if (handleNavKey(e)) return;
|
|
if (e.key === "Enter") {
|
|
if (activeIndex < 0 || activeIndex >= suggestions.length) return;
|
|
e.preventDefault();
|
|
handleSelect(suggestions[activeIndex].id);
|
|
}
|
|
},
|
|
[onCancel, handleNavKey, activeIndex, suggestions, handleSelect],
|
|
);
|
|
|
|
return (
|
|
<Popover opened onClose={onCancel} position="bottom-start" width={320} trapFocus>
|
|
<Popover.Target>
|
|
<div style={{ width: "100%", height: "100%" }}>
|
|
{resolvedPage ? <PagePill page={resolvedPage} /> : <span className={cellClasses.emptyValue} />}
|
|
</div>
|
|
</Popover.Target>
|
|
<Popover.Dropdown p={0}>
|
|
<div className={cellClasses.personTagArea}>
|
|
{pageId && resolvedPage && (
|
|
<span className={cellClasses.personTag}>
|
|
{resolvedPage.icon ? (
|
|
<span>{resolvedPage.icon}</span>
|
|
) : (
|
|
<IconFileDescription size={14} />
|
|
)}
|
|
<span className={cellClasses.personTagName}>
|
|
{resolvedPage.title || "Untitled"}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className={cellClasses.personTagRemove}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleRemove();
|
|
}}
|
|
>
|
|
<IconX size={10} />
|
|
</button>
|
|
</span>
|
|
)}
|
|
<input
|
|
ref={searchRef}
|
|
className={cellClasses.personTagInput}
|
|
placeholder={pageId ? "" : "Search for a page..."}
|
|
value={search}
|
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
|
onKeyDown={handleKeyDown}
|
|
/>
|
|
</div>
|
|
|
|
<div className={cellClasses.personDropdownDivider} />
|
|
<div className={cellClasses.selectDropdown}>
|
|
{suggestions.length === 0 && (
|
|
<div className={cellClasses.personDropdownHint}>
|
|
{trimmed ? "No pages found" : "No pages yet"}
|
|
</div>
|
|
)}
|
|
{suggestions.map((page, idx) => {
|
|
const isSelected = page.id === pageId;
|
|
return (
|
|
<div
|
|
key={page.id}
|
|
ref={setOptionRef(idx)}
|
|
className={clsx(
|
|
cellClasses.selectOption,
|
|
isSelected && cellClasses.selectOptionActive,
|
|
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
|
|
)}
|
|
onMouseEnter={() => setActiveIndex(idx)}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => handleSelect(page.id)}
|
|
>
|
|
{page.icon ? (
|
|
<span>{page.icon}</span>
|
|
) : (
|
|
<IconFileDescription size={14} />
|
|
)}
|
|
<span className={cellClasses.personOptionName}>
|
|
{page.title || "Untitled"}
|
|
</span>
|
|
{page.space?.name && (
|
|
<Text size="xs" c="dimmed" ml="auto" truncate>
|
|
{page.space.name}
|
|
</Text>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</Popover.Dropdown>
|
|
</Popover>
|
|
);
|
|
}
|