import { useState, useRef, useEffect, useCallback, useMemo } from "react"; import { Popover, ActionIcon, Text, Tooltip } 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 { useTranslation } from "react-i18next"; import clsx from "clsx"; import { IBaseProperty } from "@/ee/base/types/base.types"; import { useResolvePage } from "@/ee/base/reference/reference-store"; import { useBaseQuery } from "@/ee/base/queries/base-query"; import { searchSuggestions } from "@/features/search/services/search-service"; import { buildPageUrl, getPageTitle } from "@/features/page/page.utils"; import { usePageQuery } from "@/features/page/queries/page-query"; import { extractPageSlugId } from "@/lib"; import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav"; import cellClasses from "@/ee/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; } function parsePastedPageSlugId(input: string): string | null { const trimmed = input.trim(); if (!trimmed) return null; let path = trimmed; if (/^https?:\/\//i.test(trimmed)) { try { path = new URL(trimmed).pathname; } catch { return null; } } const match = path.match(/\/p\/([^/?#]+)/); if (!match) return null; return extractPageSlugId(match[1]) ?? null; } export function CellPage({ value, property, isEditing, onCommit, onCancel, }: CellPageProps) { const pageId = parsePageId(value); const { data: base } = useBaseQuery(property.pageId); const resolvedPage = useResolvePage(property.pageId, pageId); if (isEditing) { return ( ); } if (!pageId) { return ; } if (resolvedPage === undefined) { // placeholder to avoid "Page not found" flicker on initial load return ; } if (resolvedPage === null) { return ( Page not found ); } return ; } type PillPage = { slugId: string; title: string | null; icon: string | null; space: { slug: string } | null; }; function PagePill({ page }: { page: PillPage }) { const { t } = useTranslation(); const title = getPageTitle(page.title, undefined, t); const spaceSlug = page.space?.slug ?? ""; const url = buildPageUrl(spaceSlug, page.slugId, title); return ( e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()} > {page.icon ? ( {page.icon} ) : ( )} {title} ); } 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 { t } = useTranslation(); const [search, setSearch] = useState(""); const [debouncedSearch] = useDebouncedValue(search, 250); const searchRef = useRef(null); useEffect(() => { requestAnimationFrame(() => searchRef.current?.focus()); }, []); const trimmed = debouncedSearch.trim(); const pastedSlugId = useMemo( () => parsePastedPageSlugId(debouncedSearch), [debouncedSearch], ); 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[]; }, enabled: !pastedSlugId, staleTime: 15_000, }); // Once the pasted link resolves via slugId lookup, commit and close. const { data: linkedPage, isFetching: resolvingLink } = usePageQuery( pastedSlugId ? { pageId: pastedSlugId } : {}, ); const linkedRef = useRef(false); useEffect(() => { if (!pastedSlugId) { linkedRef.current = false; return; } if (linkedPage && !linkedRef.current) { linkedRef.current = true; onCommit(linkedPage.id); } }, [pastedSlugId, linkedPage, onCommit]); 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 ( { if (!o) onCancel(); }} onClose={onCancel} position="bottom-start" width={320} trapFocus closeOnClickOutside closeOnEscape hideDetached={false} >
{resolvedPage ? : }
{pageId && resolvedPage && ( {resolvedPage.icon ? ( {resolvedPage.icon} ) : ( )} {getPageTitle(resolvedPage.title, undefined, t)} )} setSearch(e.currentTarget.value)} onKeyDown={handleKeyDown} data-autofocus />
{pastedSlugId ? (
{resolvingLink || linkedPage ? "Linking page…" : "Page not found"}
) : ( <> {suggestions.length === 0 && (
{trimmed ? "No pages found" : "No pages yet"}
)} {suggestions.map((page, idx) => { const isSelected = page.id === pageId; return (
setActiveIndex(idx)} onClick={() => handleSelect(page.id)} > {page.icon ? ( {page.icon} ) : ( )}
{getPageTitle(page.title, undefined, t)} {page.space?.name && ( {page.space.name} )}
); })} )}
); }