From a21a0e1b9c4848bd7f13758896cdb25065f4f579 Mon Sep 17 00:00:00 2001 From: vijay-prema Date: Tue, 5 May 2026 02:01:42 +0000 Subject: [PATCH 1/7] feat(zotero): replace Search function with new interactive NanopubSearchPicker --- frontend/src/hooks/use-nanopub-search.ts | 201 +++++++ .../src/pages/np/components/GeneralSearch.tsx | 557 +++--------------- .../np/components/NanopubSearchPicker.tsx | 420 +++++++++++++ .../src/pages/np/components/SearchBar.tsx | 264 +++++++++ .../np/components/TemplateFilterSidebar.tsx | 155 +++++ zotero/addon/content/createNanopub.xhtml | 4 + zotero/addon/content/nanopubSearch.html | 40 ++ zotero/addon/content/nanopubSearch.xhtml | 28 + .../scripts/dialogs/createNanopub/bridge.js | 64 +- .../scripts/dialogs/nanopubSearch/bridge.js | 53 ++ zotero/addon/content/scripts/dialogs/utils.js | 82 +++ zotero/addon/content/styles/sciencelive.css | 2 +- zotero/src/dialogs/nanopubSearch/index.tsx | 97 +++ zotero/src/modules/scienceLivePlugin.ts | 78 ++- zotero/zotero-plugin.config.ts | 13 + 15 files changed, 1498 insertions(+), 560 deletions(-) create mode 100644 frontend/src/hooks/use-nanopub-search.ts create mode 100644 frontend/src/pages/np/components/NanopubSearchPicker.tsx create mode 100644 frontend/src/pages/np/components/SearchBar.tsx create mode 100644 frontend/src/pages/np/components/TemplateFilterSidebar.tsx create mode 100644 zotero/addon/content/nanopubSearch.html create mode 100644 zotero/addon/content/nanopubSearch.xhtml create mode 100644 zotero/addon/content/scripts/dialogs/nanopubSearch/bridge.js create mode 100644 zotero/addon/content/scripts/dialogs/utils.js create mode 100644 zotero/src/dialogs/nanopubSearch/index.tsx diff --git a/frontend/src/hooks/use-nanopub-search.ts b/frontend/src/hooks/use-nanopub-search.ts new file mode 100644 index 0000000..fb6c911 --- /dev/null +++ b/frontend/src/hooks/use-nanopub-search.ts @@ -0,0 +1,201 @@ +/** + * useNanopubSearch + * + * Shared hook that encapsulates the SPARQL fetch logic for nanopub search. + * Used by both GeneralSearch (URL-param driven) and NanopubSearchPicker + * (internal-state driven). + * + * Handles AbortController cleanup for React Strict Mode double-invocation. + */ + +import { + LATEST_ALL, + LATEST_BY_TEMPLATES, + SEARCH_NANOPUBS, + SEARCH_NANOPUBS_BY_TEMPLATES, +} from "@/lib/queries"; +import { + executeBindSparql, + NANOPUB_SPARQL_ENDPOINT_FULL, + NANOPUB_SPARQL_ENDPOINT_TEXT, +} from "@/lib/sparql"; +import { + getTemplateUris, + paginateRows, + SEARCH_MODE_PROPERTY, + SORT_ORDER_BY, + type SearchMode, + type SortOption, +} from "@/pages/np/components/SearchBar"; +import type { SearchResult } from "@/pages/np/components/SearchResultList"; +import type { FeedTemplateKey } from "@/pages/np/create/components/templates/registry-metadata"; +import { useEffect, useState } from "react"; + +export interface UseNanopubSearchParams { + /** The current search query string (empty = "latest" view). */ + searchQuery: string; + /** Effective sort option (already adjusted for isLatestView). */ + effectiveSortBy: SortOption; + /** Current search mode (label vs fullText). */ + searchMode: SearchMode; + /** Number of items to fetch (pageSize + 1 for has-more detection). */ + limit: number; + /** Offset into the result set. */ + offset: number; + /** Page size used for pagination slicing. */ + pageSize: number; + /** Set of selected template keys for filtering. */ + selectedTemplates: Set; + /** Whether we are in the "latest" (no query) view. */ + isLatestView: boolean; + /** Counter incremented to force a re-fetch with the same params. */ + refetchCounter: number; +} + +export interface UseNanopubSearchResult { + searchResults: SearchResult[] | null; + loading: boolean; + error: string | null; + hasMore: boolean; +} + +export function useNanopubSearch({ + searchQuery, + effectiveSortBy, + searchMode, + limit, + offset, + pageSize, + selectedTemplates, + isLatestView, + refetchCounter, +}: UseNanopubSearchParams): UseNanopubSearchResult { + const [searchResults, setSearchResults] = useState( + null, + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(false); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(null); + + const fetchResults = async () => { + try { + let rows: any[]; + + const sharedParams = { + sortBy: SORT_ORDER_BY[effectiveSortBy], + limit: String(limit), + offset: String(offset), + }; + + // Pre-compute template values string if filters are active + const templateValues = + selectedTemplates.size > 0 + ? getTemplateUris([...selectedTemplates]) + .map((u) => `(<${u}>)`) + .join(" ") + : undefined; + + if (isLatestView) { + // Browse latest nanopubs (no search text) + if (templateValues) { + rows = await executeBindSparql( + LATEST_BY_TEMPLATES, + { ...sharedParams, templateValues }, + NANOPUB_SPARQL_ENDPOINT_FULL, + controller.signal, + ); + } else { + rows = await executeBindSparql( + LATEST_ALL, + sharedParams, + NANOPUB_SPARQL_ENDPOINT_FULL, + controller.signal, + ); + } + } else { + // Text search with optional template filter + if (templateValues) { + rows = await executeBindSparql( + SEARCH_NANOPUBS_BY_TEMPLATES, + { + ...sharedParams, + searchTerm: searchQuery, + searchProperty: SEARCH_MODE_PROPERTY[searchMode], + templateValues, + }, + NANOPUB_SPARQL_ENDPOINT_TEXT, + controller.signal, + ); + } else { + rows = await executeBindSparql( + SEARCH_NANOPUBS, + { + ...sharedParams, + searchTerm: searchQuery, + searchProperty: SEARCH_MODE_PROPERTY[searchMode], + }, + NANOPUB_SPARQL_ENDPOINT_TEXT, + controller.signal, + ); + } + } + + const { visibleRows, hasMore: moreResultsAvailable } = paginateRows( + rows, + pageSize, + ); + + setHasMore(moreResultsAvailable); + setSearchResults( + visibleRows.map((row: any) => ({ + np: row.np, + label: row.label || row.description || "", + date: new Date(row.date), + creator: row.creator || "", + types: row.types ? row.types.split("|") : [], + template: row.template, + maxScore: + row.maxScore != null ? parseFloat(row.maxScore) : undefined, + referenceCount: + row.referenceCount != null + ? parseInt(row.referenceCount) + : undefined, + })), + ); + } catch (e: any) { + if (e?.name === "AbortError") return; + console.error("Search failed:", e); + setError(e?.message || "Search failed"); + setSearchResults(null); + setHasMore(false); + } finally { + if (!controller.signal.aborted) { + setLoading(false); + } + } + }; + + fetchResults(); + + return () => { + controller.abort(); + }; + }, [ + searchQuery, + effectiveSortBy, + searchMode, + limit, + offset, + pageSize, + selectedTemplates, + isLatestView, + refetchCounter, + ]); + + return { searchResults, loading, error, hasMore }; +} diff --git a/frontend/src/pages/np/components/GeneralSearch.tsx b/frontend/src/pages/np/components/GeneralSearch.tsx index 5e82f93..e127505 100644 --- a/frontend/src/pages/np/components/GeneralSearch.tsx +++ b/frontend/src/pages/np/components/GeneralSearch.tsx @@ -1,127 +1,37 @@ -import { NanopubIcon } from "@/components/nanopub-icon"; +/** + * GeneralSearch + * + * Combined search and results display for general keyword search and/or template filtering. + * Supports sorting, toggle full-text/title search, and results pagination. + * + * Behaviour: + * - When no query is active: shows Latest Nanopublications chronologically. + * - When query is a nanopub URI: shows a button to view that nanopub in detail. + * - When query is general keywords: shows a button to retrieve search results. + * - URL search params synced with search query text and search bar settings. + * + */ + import { PaginationControls } from "@/components/pagination-controls"; import { useTheme } from "@/components/theme-provider"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Spinner } from "@/components/ui/spinner"; -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { useNanopubSearch } from "@/hooks/use-nanopub-search"; import { usePagination } from "@/hooks/use-pagination"; -import { - LATEST_ALL, - LATEST_BY_TEMPLATES, - SEARCH_NANOPUBS, - SEARCH_NANOPUBS_BY_TEMPLATES, -} from "@/lib/queries"; -import { - executeBindSparql, - NANOPUB_SPARQL_ENDPOINT_FULL, - NANOPUB_SPARQL_ENDPOINT_TEXT, -} from "@/lib/sparql"; import { isNanopubUri } from "@/lib/uri"; -import { - FEED_GROUPS, - FEED_TEMPLATE_LABELS, - FeedTemplateKey, - getTemplateColorClass, - LEGACY_TEMPLATE_URIS, - TEMPLATE_METADATA, - TEMPLATE_URI, -} from "@/pages/np/create/components/templates/registry-metadata"; -import { TEMPLATE_VIEW_ICONS } from "@/pages/np/view/view-registry"; -import { - ArrowDownNarrowWide, - Check, - FileSymlink, - FilterX, - Minus, - Search, - X, -} from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { type FeedTemplateKey } from "@/pages/np/create/components/templates/registry-metadata"; +import { FileSymlink, Search } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; import { useSearchParams } from "react-router-dom"; -import SearchResultList, { type SearchResult } from "./SearchResultList"; - -/** Valid sort options for search results. */ -const SORT_OPTIONS = ["maxScore", "maxRefs", "dateDesc", "dateAsc"] as const; -type SortOption = (typeof SORT_OPTIONS)[number]; - -/** SPARQL ORDER BY clause for each sort option. */ -const SORT_ORDER_BY: Record = { - maxScore: "desc(?maxScore)", - maxRefs: "desc(?referenceCount)", - dateDesc: "desc(?date)", - dateAsc: "asc(?date)", -}; - -/** Valid search modes. */ -const SEARCH_MODES = ["label", "fullText"] as const; -type SearchMode = (typeof SEARCH_MODES)[number]; - -/** SPARQL search:property value for each search mode. */ -const SEARCH_MODE_PROPERTY: Record = { - label: "rdfs:label", - fullText: "npa:hasFilterLiteral", -}; - -/** Initial checked state for each template key — all unchecked by default in search */ -const INITIAL_CHECKED: Record = { - AIDA_SENTENCE: false, - CITATION_CITO: false, - ANNOTATE_QUOTATION: false, - COMMENT_PAPER: false, - GEO_COVERAGE: false, - DATASET: false, - RESEARCH_SOFTWARE: false, - ODRL_POLICY: false, - ODRL_ACCESS_GRANT: false, - PICO_RESEARCH_QUESTION: false, - PCC_RESEARCH_QUESTION: false, - PRISMA_SEARCH_STRATEGY: false, - PRISMA_DATABASE_SEARCH: false, - PRISMA_SEARCH_EXECUTION_DATASET: false, - PRISMA_STUDY_INCLUSION: false, - PRISMA_STUDY_ASSESSMENT: false, - PRISMA_FULL_SCREENING: false, - FORRT_CLAIM: false, - FORRT_REPLICATION: false, - FORRT_REPLICATION_OUTCOME: false, - FORRT_KL_REPLICATION: false, - FORRT_KL_REPLICATION_OUTCOME: false, - RESEARCH_SYNTHESIS: false, -}; - -/** Collect all URIs (current + legacy) for the selected template keys. */ -function getTemplateUris(keys: FeedTemplateKey[]): string[] { - const uris: string[] = []; - for (const key of keys) { - uris.push(TEMPLATE_URI[key]); - const legacy = LEGACY_TEMPLATE_URIS[key]; - if (legacy) { - uris.push(...legacy); - } - } - return uris; -} +import { + SEARCH_MODES, + SearchBar, + SORT_OPTIONS, + type SearchMode, + type SortOption, +} from "./SearchBar"; +import SearchResultList from "./SearchResultList"; +import { TemplateFilterSidebar } from "./TemplateFilterSidebar"; -/** - * GeneralSearch - * - * Combined search input and results display for general keyword search. - * - * - When no query is active: shows large search input, helper text, and example links - * - When query is active: shows compact search bar at top and results below - * - * Handles both keyword search and nanopub URI navigation. - * Supports pagination via the `page` URL search parameter. - * Supports filtering by template type via the sidebar. - */ export function GeneralSearch() { const { resolvedTheme } = useTheme(); const [searchParams, setSearchParams] = useSearchParams(); @@ -140,58 +50,18 @@ export function GeneralSearch() { usePagination(); const [inputValue, setInputValue] = useState(searchQuery || uri); - const [searchResults, setSearchResults] = useState( - null, - ); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); /** Incremented to force a re-fetch when the same query is re-submitted. */ const [refetchCounter, setRefetchCounter] = useState(0); - /** Whether there are likely more results beyond the current page. */ - const [hasMore, setHasMore] = useState(false); - - const [checked, setChecked] = - useState>(INITIAL_CHECKED); - const selectedTemplates = useMemo(() => { - const s = new Set(); - for (const [key, val] of Object.entries(checked)) { - if (val) s.add(key as FeedTemplateKey); - } - return s; - }, [checked]); + const [selectedTemplates, setSelectedTemplates] = useState< + Set + >(new Set()); const isNanopubInput = isNanopubUri(inputValue); - const toggleTemplate = useCallback( - (key: FeedTemplateKey) => { - setChecked((prev) => ({ ...prev, [key]: !prev[key] })); - resetPage(); - }, - [resetPage], - ); - - const clearFilters = useCallback(() => { - setChecked((prev) => { - const next = { ...prev }; - for (const key of Object.keys(next) as FeedTemplateKey[]) { - next[key] = false; - } - return next; - }); - resetPage(); - }, [resetPage]); - - const toggleGroup = useCallback( - (keys: FeedTemplateKey[]) => { - setChecked((prev) => { - const allOn = keys.every((k) => prev[k]); - const next = { ...prev }; - for (const k of keys) { - next[k] = !allOn; - } - return next; - }); + const handleSelectedTemplatesChange = useCallback( + (selected: Set) => { + setSelectedTemplates(selected); resetPage(); }, [resetPage], @@ -209,130 +79,18 @@ export function GeneralSearch() { const effectiveSortBy: SortOption = isLatestView && sortBy === "maxScore" ? "dateDesc" : sortBy; - // Load results when searchQuery, page, or template filters change - useEffect(() => { - const controller = new AbortController(); - setLoading(true); - setError(null); - - const fetchResults = async () => { - try { - let rows: any[]; - - // Shared params included in every query - const sharedParams = { - sortBy: SORT_ORDER_BY[effectiveSortBy], - limit: String(limit), - offset: String(offset), - }; - - // Pre-compute template values string if filters are active - const templateValues = - selectedTemplates.size > 0 - ? getTemplateUris([...selectedTemplates]) - .map((u) => `(<${u}>)`) - .join(" ") - : undefined; - - if (isLatestView) { - // Browse latest nanopubs (no search text) - if (templateValues) { - rows = await executeBindSparql( - LATEST_BY_TEMPLATES, - { ...sharedParams, templateValues }, - NANOPUB_SPARQL_ENDPOINT_FULL, - controller.signal, - ); - } else { - rows = await executeBindSparql( - LATEST_ALL, - sharedParams, - NANOPUB_SPARQL_ENDPOINT_FULL, - controller.signal, - ); - } - } else { - // Text search with optional template filter - if (templateValues) { - rows = await executeBindSparql( - SEARCH_NANOPUBS_BY_TEMPLATES, - { - ...sharedParams, - searchTerm: searchQuery, - searchProperty: SEARCH_MODE_PROPERTY[searchMode], - templateValues, - }, - NANOPUB_SPARQL_ENDPOINT_TEXT, - controller.signal, - ); - } else { - rows = await executeBindSparql( - SEARCH_NANOPUBS, - { - ...sharedParams, - searchTerm: searchQuery, - searchProperty: SEARCH_MODE_PROPERTY[searchMode], - }, - NANOPUB_SPARQL_ENDPOINT_TEXT, - controller.signal, - ); - } - } - - const { visibleRows, hasMore: moreResultsAvailable } = - paginateRows(rows); - - setHasMore(moreResultsAvailable); - setSearchResults( - visibleRows.map((row: any) => ({ - np: row.np, - label: row.label || row.description || "", - date: new Date(row.date), - creator: row.creator || "", - types: row.types ? row.types.split("|") : [], - template: row.template, - maxScore: - row.maxScore != null ? parseFloat(row.maxScore) : undefined, - referenceCount: - row.referenceCount != null - ? parseInt(row.referenceCount) - : undefined, - })), - ); - } catch (e: any) { - if (e?.name === "AbortError") return; - console.error("Search failed:", e); - setError(e?.message || "Search failed"); - setSearchResults(null); - setHasMore(false); - } finally { - if (!controller.signal.aborted) { - setLoading(false); - } - } - }; - - fetchResults(); - - return () => { - controller.abort(); - }; - }, [ + // ---- Fetch results via shared hook ---- + const { searchResults, loading, error, hasMore } = useNanopubSearch({ searchQuery, effectiveSortBy, searchMode, - currentPage, + limit, + offset, + pageSize, selectedTemplates, isLatestView, refetchCounter, - ]); - - /** Paginate raw SPARQL rows using the current page size. */ - function paginateRows(rows: T[]) { - const hasMoreRows = rows.length > pageSize; - const visibleRows = hasMoreRows ? rows.slice(0, pageSize) : rows; - return { visibleRows, hasMore: hasMoreRows }; - } + }); const handleSubmit = () => { if (isNanopubUri(inputValue)) { @@ -355,7 +113,6 @@ export function GeneralSearch() { setSearchParams(next); // Force re-fetch even if params are unchanged setRefetchCounter((c) => c + 1); - setLoading(true); } }; @@ -368,184 +125,13 @@ export function GeneralSearch() { }; /** Update search mode parameter in URL, resetting to page 1. */ - const handleSearchModeChange = (value: string) => { + const handleSearchModeChange = (value: SearchMode) => { const next = new URLSearchParams(searchParams); - next.set("mode", value as SearchMode); + next.set("mode", value); next.delete("page"); setSearchParams(next); }; - const renderSearchModeToggle = (size: "sm" | "default" = "default") => ( -
- Search in: - { - if (v) handleSearchModeChange(v); - }} - variant="outline" - size={size} - > - - Full-text - - - Title only - - -
- ); - - const renderSortSelect = (size: "sm" | "default" = "default") => ( -
- Sort by: - -
- ); - - const renderSearchBar = () => ( -
-
-
- setInputValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleSubmit(); - } - }} - /> - {inputValue && ( - - )} -
- -
-
- {renderSearchModeToggle("sm")} - {renderSortSelect("sm")} -
-
- ); - - const renderTemplateSidebar = () => ( - - ); - // Render search results (used for both latest and search views) const renderSearchResults = () => { if (loading) { @@ -607,30 +193,49 @@ export function GeneralSearch() { return (
- {renderSearchBar()} + { + setInputValue(""); + const next = new URLSearchParams(searchParams); + next.delete("q"); + next.delete("uri"); + next.delete("page"); + setSearchParams(next); + }} + placeholder="Enter search query or nanopub URI..." + loading={loading} + searchMode={searchMode} + onSearchModeChange={handleSearchModeChange} + effectiveSortBy={effectiveSortBy} + onSortChange={handleSortChange} + isLatestView={isLatestView} + submitContent={ + isNanopubInput ? ( + <> + + View + + ) : ( + <> + + Go + + ) + } + submitClassName="inline-flex items-center rounded-md bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-50 px-6" + />
-
{renderTemplateSidebar()}
+
+ +
{renderSearchResults()}
); } - -function FeedCheckbox({ - state, -}: { - state: "checked" | "unchecked" | "indeterminate"; -}) { - return ( - - {state === "checked" && } - {state === "indeterminate" && } - - ); -} diff --git a/frontend/src/pages/np/components/NanopubSearchPicker.tsx b/frontend/src/pages/np/components/NanopubSearchPicker.tsx new file mode 100644 index 0000000..8fdf5e1 --- /dev/null +++ b/frontend/src/pages/np/components/NanopubSearchPicker.tsx @@ -0,0 +1,420 @@ +/** + * NanopubSearchPicker + * + * A simplified embeddable nanopub search component with multi-select capability. + * Designed to work standalone without react-router (e.g. in Zotero iframe context). + * + * Primary use-case is for searching and selecting multiple nanopublications for + * further processing (e.g. importing into Zotero). + * + * Reuses the same SPARQL queries and search logic as GeneralSearch, but manages + * all state internally instead of via URL search params. + */ + +import { NanopubIcon } from "@/components/nanopub-icon"; +import { PaginationControls } from "@/components/pagination-controls"; +import { RelativeDateTime } from "@/components/relative-datetime"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Spinner } from "@/components/ui/spinner"; +import { AsyncLabel } from "@/hooks/use-labels"; +import { useNanopubSearch } from "@/hooks/use-nanopub-search"; +import { getUriEnd } from "@/lib/uri"; +import { + type FeedTemplateKey, + getTemplateColorClass, + TEMPLATE_METADATA, +} from "@/pages/np/create/components/templates/registry-metadata"; +import { TEMPLATE_VIEW_ICONS } from "@/pages/np/view/view-registry"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + FilterCheckbox, + SearchBar, + type SearchMode, + type SortOption, +} from "./SearchBar"; +import { TemplateFilterSidebar } from "./TemplateFilterSidebar"; + +// Detect dark mode from document class (works in Zotero iframe context) +// TODO: if you use NanopubSearchPicker elsewhere, you may need to use a different method +const zoteroTheme = document.documentElement.classList.contains("dark") + ? "dark" + : "light"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEFAULT_PAGE_SIZE = 10; + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +/** Template icon for a search result (standalone, no useTheme dependency). */ +function PickerTemplateIcon({ template }: { template?: string }) { + const Icon = template ? TEMPLATE_VIEW_ICONS[template] : undefined; + const color = template ? TEMPLATE_METADATA[template]?.color : undefined; + return Icon ? ( + + ) : ( + + ); +} + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +export interface NanopubSearchPickerProps { + /** Pre-fill the search input and auto-trigger search on mount. */ + initialQuery?: string; + /** Optional Label to show on the confirm button. */ + confirmLabel?: string; + /** Called when the user confirms their selection. */ + onConfirm: (selectedUris: string[]) => void; + /** Called when the user cancels. */ + onCancel: () => void; +} + +// --------------------------------------------------------------------------- +// Main Component +// --------------------------------------------------------------------------- + +export function NanopubSearchPicker({ + initialQuery = "", + confirmLabel = "Confirm Selection", + onConfirm, + onCancel, +}: NanopubSearchPickerProps) { + // ---- Search state (replaces URL search params) ---- + const [searchQuery, setSearchQuery] = useState(initialQuery); + const [inputValue, setInputValue] = useState(initialQuery); + const [sortBy, setSortBy] = useState("dateDesc"); + const [searchMode, setSearchMode] = useState("label"); + const [currentPage, setCurrentPage] = useState(1); + const [refetchCounter, setRefetchCounter] = useState(0); + + // ---- Selection state ---- + const [selectedUris, setSelectedUris] = useState>(new Set()); + + // ---- Template filter state ---- + const [selectedTemplates, setSelectedTemplates] = useState< + Set + >(new Set()); + + // ---- Pagination helpers ---- + const pageSize = DEFAULT_PAGE_SIZE; + const offset = (currentPage - 1) * pageSize; + const limit = pageSize + 1; + + const resetPage = useCallback(() => setCurrentPage(1), []); + + // ---- Template filter callback ---- + const handleSelectedTemplatesChange = useCallback( + (selected: Set) => { + setSelectedTemplates(selected); + resetPage(); + }, + [resetPage], + ); + + // ---- Selection callbacks ---- + const toggleSelection = useCallback((uri: string) => { + setSelectedUris((prev) => { + const next = new Set(prev); + if (next.has(uri)) { + next.delete(uri); + } else { + next.add(uri); + } + return next; + }); + }, []); + + const clearSelection = useCallback(() => { + setSelectedUris(new Set()); + }, []); + + // Whether we are showing the "latest nanopubs" default view (no search query) + const isLatestView = !searchQuery; + + /** Effective sort: "Relevance" (maxScore) is only available with a search query. */ + const effectiveSortBy: SortOption = + isLatestView && sortBy === "maxScore" ? "dateDesc" : sortBy; + + // ---- Auto-search on mount if initialQuery is provided ---- + const hasAutoSearched = useRef(false); + useEffect(() => { + if (initialQuery && !hasAutoSearched.current) { + hasAutoSearched.current = true; + setSearchQuery(initialQuery); + // Sort by relevance when searching + setSortBy("maxScore"); + } + }, [initialQuery]); + + // ---- Fetch results via shared hook ---- + const { searchResults, loading, error, hasMore } = useNanopubSearch({ + searchQuery, + effectiveSortBy, + searchMode, + limit, + offset, + pageSize, + selectedTemplates, + isLatestView, + refetchCounter, + }); + + const allVisibleSelected = useMemo(() => { + if (!searchResults || searchResults.length === 0) return false; + return searchResults.every((r) => selectedUris.has(r.np)); + }, [searchResults, selectedUris]); + + const toggleSelectAll = useCallback(() => { + if (!searchResults) return; + const visibleUris = searchResults.map((r) => r.np); + setSelectedUris((prev) => { + const allSelected = visibleUris.every((uri) => prev.has(uri)); + const next = new Set(prev); + if (allSelected) { + for (const uri of visibleUris) { + next.delete(uri); + } + } else { + for (const uri of visibleUris) { + next.add(uri); + } + } + return next; + }); + }, [searchResults]); + + // ---- Handlers ---- + const handleSubmit = () => { + const trimmed = inputValue.trim(); + setSearchQuery(trimmed); + setCurrentPage(1); + if (trimmed) { + setSortBy("maxScore"); + } + setRefetchCounter((c) => c + 1); + }; + + const handleSortChange = (value: SortOption) => { + setSortBy(value); + setCurrentPage(1); + }; + + const handleSearchModeChange = (value: SearchMode) => { + setSearchMode(value); + setCurrentPage(1); + }; + + // ---- Render helpers ---- + const renderSearchResults = () => { + if (loading) { + return ( +
+ {" "} + {isLatestView ? "Loading latest…" : "Searching…"} +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!searchResults) return null; + + const firstResultIndex = (currentPage - 1) * pageSize + 1; + const lastResultIndex = firstResultIndex + searchResults.length - 1; + + return ( +
+
+

+ {isLatestView + ? searchResults.length > 0 + ? selectedTemplates.size > 0 + ? "Nanopublications" + : "Latest Nanopublications" + : "No nanopublications found" + : searchResults.length > 0 + ? `Results ${firstResultIndex}–${lastResultIndex} for "${searchQuery}"` + : `No results for "${searchQuery}"`} + {selectedTemplates.size > 0 && " with selected Template(s)"} +

+ {searchResults.length > 0 && ( + + )} +
+ + {searchResults.length > 0 ? ( +
+ {searchResults.map((result, index) => { + const isSelected = selectedUris.has(result.np); + return ( +
toggleSelection(result.np)} + > + toggleSelection(result.np)} + className="mt-1 shrink-0" + /> +
+ {/* Label/Title */} +
+ + + {result.label || "Untitled Nanopublication"} + +
+ + {/* Creator URI (simplified — no async label lookup) */} + {result.creator && ( +
+ By +
+ )} + + {/* Type badges */} + {result.types && result.types.length > 0 && ( +
+ {result.types.map((type) => ( + + {getUriEnd(type)} + + ))} +
+ )} + + {/* Date */} + {result.date && ( +
+ +
+ )} +
+
+ ); + })} +
+ ) : ( +
+ {isLatestView + ? "No nanopublications found." + : "No results found for your search."} +
+ )} + + +
+ ); + }; + + // ---- Main render ---- + return ( +
+ {/* Search bar */} + { + setInputValue(""); + setSearchQuery(""); + setCurrentPage(1); + }} + placeholder="Enter search query..." + loading={loading} + searchMode={searchMode} + onSearchModeChange={handleSearchModeChange} + effectiveSortBy={effectiveSortBy} + onSortChange={handleSortChange} + isLatestView={isLatestView} + /> + + {/* Content area: sidebar + results */} +
+
+ +
+
+ {renderSearchResults()} +
+
+ + {/* Sticky bottom action bar */} +
+
+ {selectedUris.size > 0 ? ( + <> + + {selectedUris.size} + {" "} + selected + + + ) : ( + "Select nanopublications from the results above" + )} +
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/pages/np/components/SearchBar.tsx b/frontend/src/pages/np/components/SearchBar.tsx new file mode 100644 index 0000000..487d2e8 --- /dev/null +++ b/frontend/src/pages/np/components/SearchBar.tsx @@ -0,0 +1,264 @@ +/** + * SearchBar + * + * Shared search bar component used by both GeneralSearch and NanopubSearchPicker. + * Includes the search input, submit button, search-mode toggle, and sort select. + * + */ + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Spinner } from "@/components/ui/spinner"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { + LEGACY_TEMPLATE_URIS, + TEMPLATE_URI, + type FeedTemplateKey, +} from "@/pages/np/create/components/templates/registry-metadata"; +import { ArrowDownNarrowWide, Check, Minus, Search, X } from "lucide-react"; +import type { ReactNode } from "react"; + +// --------------------------------------------------------------------------- +// Shared constants & types +// --------------------------------------------------------------------------- + +/** Valid sort options for search results. */ +export const SORT_OPTIONS = [ + "maxScore", + "maxRefs", + "dateDesc", + "dateAsc", +] as const; +export type SortOption = (typeof SORT_OPTIONS)[number]; + +/** SPARQL ORDER BY clause for each sort option. */ +export const SORT_ORDER_BY: Record = { + maxScore: "desc(?maxScore)", + maxRefs: "desc(?referenceCount)", + dateDesc: "desc(?date)", + dateAsc: "asc(?date)", +}; + +/** Valid search modes. */ +export const SEARCH_MODES = ["label", "fullText"] as const; +export type SearchMode = (typeof SEARCH_MODES)[number]; + +/** SPARQL search:property value for each search mode. */ +export const SEARCH_MODE_PROPERTY: Record = { + label: "rdfs:label", + fullText: "npa:hasFilterLiteral", +}; + +/** Initial checked state for each template key — all unchecked by default */ +export const INITIAL_CHECKED: Record = { + AIDA_SENTENCE: false, + CITATION_CITO: false, + ANNOTATE_QUOTATION: false, + COMMENT_PAPER: false, + GEO_COVERAGE: false, + DATASET: false, + RESEARCH_SOFTWARE: false, + ODRL_POLICY: false, + ODRL_ACCESS_GRANT: false, + PICO_RESEARCH_QUESTION: false, + PCC_RESEARCH_QUESTION: false, + PRISMA_SEARCH_STRATEGY: false, + PRISMA_DATABASE_SEARCH: false, + PRISMA_SEARCH_EXECUTION_DATASET: false, + PRISMA_STUDY_INCLUSION: false, + PRISMA_STUDY_ASSESSMENT: false, + PRISMA_FULL_SCREENING: false, + FORRT_CLAIM: false, + FORRT_REPLICATION: false, + FORRT_REPLICATION_OUTCOME: false, + FORRT_KL_REPLICATION: false, + FORRT_KL_REPLICATION_OUTCOME: false, + RESEARCH_SYNTHESIS: false, +}; + +/** Collect all URIs (current + legacy) for the selected template keys. */ +export function getTemplateUris(keys: FeedTemplateKey[]): string[] { + const uris: string[] = []; + for (const key of keys) { + uris.push(TEMPLATE_URI[key]); + const legacy = LEGACY_TEMPLATE_URIS[key]; + if (legacy) { + uris.push(...legacy); + } + } + return uris; +} + +/** Paginate raw SPARQL rows using the given page size. */ +export function paginateRows(rows: T[], pageSize: number) { + const hasMoreRows = rows.length > pageSize; + const visibleRows = hasMoreRows ? rows.slice(0, pageSize) : rows; + return { visibleRows, hasMore: hasMoreRows }; +} + +// --------------------------------------------------------------------------- +// FilterCheckbox (shared between sidebar and result header) +// --------------------------------------------------------------------------- + +export function FilterCheckbox({ + state, +}: { + state: "checked" | "unchecked" | "indeterminate"; +}) { + return ( + + {state === "checked" && } + {state === "indeterminate" && } + + ); +} + +// --------------------------------------------------------------------------- +// SearchBar component +// --------------------------------------------------------------------------- + +export interface SearchBarProps { + /** Current value of the search input. */ + inputValue: string; + /** Called when the input value changes. */ + onInputChange: (value: string) => void; + /** Called when the user submits the search (Enter key or button click). */ + onSubmit: () => void; + /** Called when the user clicks the clear (X) button. */ + onClear: () => void; + /** Placeholder text for the input. */ + placeholder?: string; + /** Whether a search is currently in progress. */ + loading: boolean; + /** Current search mode. */ + searchMode: SearchMode; + /** Called when the search mode changes. */ + onSearchModeChange: (mode: SearchMode) => void; + /** Effective sort option (respects isLatestView override). */ + effectiveSortBy: SortOption; + /** Called when the sort option changes. */ + onSortChange: (sort: SortOption) => void; + /** Whether we are in the "latest" view (disables Relevance sort). */ + isLatestView: boolean; + /** Optional custom content for the submit button. Defaults to Search icon + "Search". */ + submitContent?: ReactNode; + /** Optional extra className for the submit Button. */ + submitClassName?: string; +} + +export function SearchBar({ + inputValue, + onInputChange, + onSubmit, + onClear, + placeholder = "Search nanopublications...", + loading, + searchMode, + onSearchModeChange, + effectiveSortBy, + onSortChange, + isLatestView, + submitContent, + submitClassName, +}: SearchBarProps) { + return ( +
+
+
+ onInputChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + onSubmit(); + } + }} + /> + {inputValue && ( + + )} +
+ +
+
+
+ Search in: + { + if (v) onSearchModeChange(v as SearchMode); + }} + variant="outline" + size="sm" + > + + Full-text + + + Title only + + +
+
+ Sort by: + +
+
+
+ ); +} diff --git a/frontend/src/pages/np/components/TemplateFilterSidebar.tsx b/frontend/src/pages/np/components/TemplateFilterSidebar.tsx new file mode 100644 index 0000000..a45e59c --- /dev/null +++ b/frontend/src/pages/np/components/TemplateFilterSidebar.tsx @@ -0,0 +1,155 @@ +/** + * TemplateFilterSidebar + * + * Sidebar component for filtering nanopublications by template type. + * The resolvedTheme is passed in, enabling use in environments that are not + * compatible with the normal useTheme hook. + * + * Notifies the parent via `onSelectedTemplatesChange` whenever the selection changes + * (so the parent can e.g. reset the page). + */ + +import { NanopubIcon } from "@/components/nanopub-icon"; +import { + FEED_GROUPS, + FEED_TEMPLATE_LABELS, + type FeedTemplateKey, + getTemplateColorClass, + TEMPLATE_METADATA, + TEMPLATE_URI, +} from "@/pages/np/create/components/templates/registry-metadata"; +import { TEMPLATE_VIEW_ICONS } from "@/pages/np/view/view-registry"; +import { FilterX } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { FilterCheckbox, INITIAL_CHECKED } from "./SearchBar"; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +export interface TemplateFilterSidebarProps { + /** Resolved theme ("light" or "dark"), used for template icon coloring. */ + resolvedTheme: "light" | "dark"; + /** Called whenever the set of selected template keys changes. */ + onSelectedTemplatesChange: (selected: Set) => void; + /** Additional className for the aside element. */ + className?: string; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function TemplateFilterSidebar({ + resolvedTheme, + onSelectedTemplatesChange, + className = "", +}: TemplateFilterSidebarProps) { + const [checked, setChecked] = + useState>(INITIAL_CHECKED); + + const selectedTemplates = useMemo(() => { + const s = new Set(); + for (const [key, val] of Object.entries(checked)) { + if (val) s.add(key as FeedTemplateKey); + } + return s; + }, [checked]); + + // Notify parent whenever selectedTemplates changes + useEffect(() => { + onSelectedTemplatesChange(selectedTemplates); + }, [selectedTemplates, onSelectedTemplatesChange]); + + const toggleTemplate = useCallback((key: FeedTemplateKey) => { + setChecked((prev) => ({ ...prev, [key]: !prev[key] })); + }, []); + + const clearFilters = useCallback(() => { + setChecked((prev) => { + const next = { ...prev }; + for (const key of Object.keys(next) as FeedTemplateKey[]) { + next[key] = false; + } + return next; + }); + }, []); + + const toggleGroup = useCallback((keys: FeedTemplateKey[]) => { + setChecked((prev) => { + const allOn = keys.every((k) => prev[k]); + const next = { ...prev }; + for (const k of keys) { + next[k] = !allOn; + } + return next; + }); + }, []); + + return ( + + ); +} diff --git a/zotero/addon/content/createNanopub.xhtml b/zotero/addon/content/createNanopub.xhtml index a10d661..73b295e 100644 --- a/zotero/addon/content/createNanopub.xhtml +++ b/zotero/addon/content/createNanopub.xhtml @@ -33,6 +33,10 @@ The React bundle runs inside the iframe (content context) and cannot reliably access Zotero globals like Zotero.Prefs, so the chrome parent injects the values. --> + + + + \ No newline at end of file diff --git a/zotero/addon/content/nanopubSearch.xhtml b/zotero/addon/content/nanopubSearch.xhtml new file mode 100644 index 0000000..7e96454 --- /dev/null +++ b/zotero/addon/content/nanopubSearch.xhtml @@ -0,0 +1,28 @@ + + + + + + + + /* Host a normal HTML page so module scripts can run reliably */ + #nanopub-search-browser { width: 100%; height: 100%; min-height: 600px; } + + + + + + + - - - \ No newline at end of file + + diff --git a/zotero/addon/content/nanopubSearch.xhtml b/zotero/addon/content/nanopubSearch.xhtml index 7e96454..4baa449 100644 --- a/zotero/addon/content/nanopubSearch.xhtml +++ b/zotero/addon/content/nanopubSearch.xhtml @@ -1,28 +1,47 @@ - - - - - + + + + + - - /* Host a normal HTML page so module scripts can run reliably */ - #nanopub-search-browser { width: 100%; height: 100%; min-height: 600px; } - - + + /* Host a normal HTML page so module scripts can run reliably */ + #nanopub-search-browser { width: 100%; height: 100%; min-height: 600px; } + + - - -