diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b2e071d..e1e860a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -31,7 +31,10 @@ }, "ghcr.io/dhoeric/features/act:1": {}, "ghcr.io/devcontainers/features/python:1": {}, - "ghcr.io/devcontainers-extra/features/uv:1": {} + "ghcr.io/devcontainers-extra/features/uv:1": {}, + "ghcr.io/postfinance/devcontainer-features/browsers:1.0.0": { + "firefoxVersion": "latest" + } }, "postCreateCommand": "bash scripts/devcontainer-setup.sh", "customizations": { diff --git a/frontend/src/hooks/use-nanopub-search.ts b/frontend/src/hooks/use-nanopub-search.ts new file mode 100644 index 0000000..a8f4d04 --- /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/search/SearchBar"; +import type { SearchResult } from "@/pages/np/components/search/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/lib/external-url.ts b/frontend/src/lib/external-url.ts new file mode 100644 index 0000000..701a0c8 --- /dev/null +++ b/frontend/src/lib/external-url.ts @@ -0,0 +1,45 @@ +/** + * Utility for opening external URLs that works in both browser and Zotero contexts. + * + * In a normal browser, `` works fine. But inside a Zotero + * XUL dialog iframe (`type="content"`), link clicks are silently swallowed. + * + * Components that need to open external URLs should accept an optional + * `onOpenExternalUrl` callback prop. In Zotero context, this callback is wired + * to `Zotero.launchURL()` via the bridge scripts. In normal browser context, + * the fallback `window.open()` is used. + */ + +/** Callback type for opening external URLs. */ +export type OpenExternalUrlFn = (url: string) => void; + +/** + * Default implementation: opens a URL in a new browser tab. + * Used as fallback when no Zotero-specific callback is provided. + */ +export const defaultOpenExternalUrl: OpenExternalUrlFn = (url: string) => { + window.open(url, "_blank", "noopener,noreferrer"); +}; + +/** + * Create a click handler for `` elements that opens the link via the + * provided callback (or falls back to `window.open`). + * + * Usage: + * ```tsx + * + * ``` + */ +export function makeExternalLinkHandler( + onOpenExternalUrl?: OpenExternalUrlFn, +): (e: React.MouseEvent) => void { + const opener = onOpenExternalUrl ?? defaultOpenExternalUrl; + return (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const url = e.currentTarget.href; + if (url) { + opener(url); + } + }; +} diff --git a/frontend/src/pages/np/GeoMap.tsx b/frontend/src/pages/np/GeoMap.tsx index 87db5c7..6480d17 100644 --- a/frontend/src/pages/np/GeoMap.tsx +++ b/frontend/src/pages/np/GeoMap.tsx @@ -16,7 +16,6 @@ import { executeBindSparql, NANOPUB_SPARQL_ENDPOINT_FULL } from "@/lib/sparql"; import type { LatLngExpression } from "leaflet"; import { Globe, Search } from "lucide-react"; import { useCallback, useRef, useState } from "react"; -import SearchResultList from "./components/SearchResultList"; import { GeoLayers, LocationDetail, @@ -25,6 +24,7 @@ import { type GeoLocation, type MapBounds, } from "./components/geo"; +import SearchResultList from "./components/search/SearchResultList"; // --------------------------------------------------------------------------- // Main Page Component diff --git a/frontend/src/pages/np/components/GeneralSearch.tsx b/frontend/src/pages/np/components/GeneralSearch.tsx index 5e82f93..7f52a3d 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 "./search/SearchBar"; +import SearchResultList from "./search/SearchResultList"; +import { TemplateFilterSidebar } from "./search/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/GeoSearch.tsx b/frontend/src/pages/np/components/GeoSearch.tsx index a079479..631a511 100644 --- a/frontend/src/pages/np/components/GeoSearch.tsx +++ b/frontend/src/pages/np/components/GeoSearch.tsx @@ -8,7 +8,6 @@ import type { LatLngBoundsExpression, LatLngExpression } from "leaflet"; import { Globe, MapPinned } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useMap } from "react-leaflet"; -import SearchResultList from "./SearchResultList"; import { GeoLayers, LocationDetail, @@ -17,6 +16,7 @@ import { type GeoLocation, type MapBounds, } from "./geo"; +import SearchResultList from "./search/SearchResultList"; /** Approximate bounding box for Southern Europe, used in Geo example. */ const EUROPE_BOUNDS: LatLngBoundsExpression = [ diff --git a/frontend/src/pages/np/components/NanopubReferences.tsx b/frontend/src/pages/np/components/NanopubReferences.tsx index 2121190..13d8af2 100644 --- a/frontend/src/pages/np/components/NanopubReferences.tsx +++ b/frontend/src/pages/np/components/NanopubReferences.tsx @@ -12,7 +12,7 @@ import { } from "@radix-ui/react-collapsible"; import { ChevronDown, FileSymlink } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import SearchResultList, { SearchResult } from "./SearchResultList"; +import SearchResultList, { SearchResult } from "./search/SearchResultList"; /** * An expandable panel that shows all nanopubs that reference the current nanopub. diff --git a/frontend/src/pages/np/components/NanopubSearchPicker.tsx b/frontend/src/pages/np/components/NanopubSearchPicker.tsx new file mode 100644 index 0000000..1c56530 --- /dev/null +++ b/frontend/src/pages/np/components/NanopubSearchPicker.tsx @@ -0,0 +1,530 @@ +/** + * 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). Includes the ability to quickly + * preview the text content of any nanopub search result without leaving the page. + * + * 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 { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Spinner } from "@/components/ui/spinner"; +import { AsyncLabel } from "@/hooks/use-labels"; +import { useNanopubSearch } from "@/hooks/use-nanopub-search"; +import { + makeExternalLinkHandler, + type OpenExternalUrlFn, +} from "@/lib/external-url"; +import { NanopubStore } from "@/lib/nanopub-store"; +import { getUriEnd, toScienceLiveNPUri } 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 { ExternalLink, Eye } from "lucide-react"; +import { marked } from "marked"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + FilterCheckbox, + SearchBar, + type SearchMode, + type SortOption, +} from "./search/SearchBar"; +import { TemplateFilterSidebar } from "./search/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 = 20; + +// --------------------------------------------------------------------------- +// 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 ? ( + + ) : ( + + ); +} + +/** Popover that loads a nanopub and renders its markdown preview. */ +function NanopubPreviewPopover({ uri }: { uri: string }) { + const [open, setOpen] = useState(false); + const [html, setHtml] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) { + setHtml(null); + setError(null); + return; + } + let cancelled = false; + setLoading(true); + NanopubStore.load(uri) + .then((store) => { + if (cancelled) return; + const md = store.toMarkdownString(); + setHtml(marked.parse(md) as string); + setLoading(false); + }) + .catch((err) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : String(err)); + setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open, uri]); + + return ( + + + + + e.stopPropagation()} + > + {loading && ( +
+ Loading preview… +
+ )} + {error && ( +
+ Failed to load preview: {error} +
+ )} + {html && ( +
+ )} + + + ); +} + +// --------------------------------------------------------------------------- +// 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; + /** + * Optional callback to open a URL in an external browser. + * In Zotero context this delegates to `Zotero.launchURL()`. + * When omitted, falls back to `window.open()`. + */ + onOpenExternalUrl?: OpenExternalUrlFn; +} + +// --------------------------------------------------------------------------- +// Main Component +// --------------------------------------------------------------------------- + +export function NanopubSearchPicker({ + initialQuery = "", + confirmLabel = "Confirm Selection", + onConfirm, + onCancel, + onOpenExternalUrl, +}: NanopubSearchPickerProps) { + // ---- External link handler (memoized) ---- + const externalLinkClickHandler = useMemo( + () => makeExternalLinkHandler(onOpenExternalUrl), + [onOpenExternalUrl], + ); + + // ---- 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 ? ( +
+ ) : ( +
+ {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/ai-query/QueryResults.tsx b/frontend/src/pages/np/components/ai-query/QueryResults.tsx index c11db79..d2f84fa 100644 --- a/frontend/src/pages/np/components/ai-query/QueryResults.tsx +++ b/frontend/src/pages/np/components/ai-query/QueryResults.tsx @@ -5,7 +5,7 @@ import { executeSparql, NANOPUB_SPARQL_ENDPOINT_TEXT } from "@/lib/sparql"; import { AlertCircle, Loader2 } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; -import SearchResultList, { SearchResult } from "../SearchResultList"; +import SearchResultList, { SearchResult } from "../search/SearchResultList"; import type { QueryResultItem } from "./types"; interface QueryResultsProps { diff --git a/frontend/src/pages/np/components/search/SearchBar.tsx b/frontend/src/pages/np/components/search/SearchBar.tsx new file mode 100644 index 0000000..487d2e8 --- /dev/null +++ b/frontend/src/pages/np/components/search/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/SearchResultList.tsx b/frontend/src/pages/np/components/search/SearchResultList.tsx similarity index 96% rename from frontend/src/pages/np/components/SearchResultList.tsx rename to frontend/src/pages/np/components/search/SearchResultList.tsx index 47321c6..0487d82 100644 --- a/frontend/src/pages/np/components/SearchResultList.tsx +++ b/frontend/src/pages/np/components/search/SearchResultList.tsx @@ -10,8 +10,8 @@ import { getTemplateColorClass, getTemplateMetadata, resolveTemplateUri, -} from "../create/components/templates/registry-metadata"; -import { TEMPLATE_VIEW_ICONS } from "../view/view-registry"; +} from "../../create/components/templates/registry-metadata"; +import { TEMPLATE_VIEW_ICONS } from "../../view/view-registry"; export interface SearchResult { np: string; diff --git a/frontend/src/pages/np/components/search/TemplateFilterSidebar.tsx b/frontend/src/pages/np/components/search/TemplateFilterSidebar.tsx new file mode 100644 index 0000000..a45e59c --- /dev/null +++ b/frontend/src/pages/np/components/search/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/frontend/src/pages/np/create/components/NanopubEditor.tsx b/frontend/src/pages/np/create/components/NanopubEditor.tsx index 1ee8246..b5c688d 100644 --- a/frontend/src/pages/np/create/components/NanopubEditor.tsx +++ b/frontend/src/pages/np/create/components/NanopubEditor.tsx @@ -40,7 +40,7 @@ import { import { useEffect, useRef, useState } from "react"; import { Link } from "react-router-dom"; import { toast } from "sonner"; -import { NanopubTemplateIcon } from "../../components/SearchResultList"; +import { NanopubTemplateIcon } from "../../components/search/SearchResultList"; import { NanopubViewer } from "../../view/NanopubViewer"; import { TEMPLATE_VIEW_ICONS } from "../../view/view-registry"; import AnyStatementTemplate from "./AnyStatementTemplate"; diff --git a/package-lock.json b/package-lock.json index 2ac958b..7009ddc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36305,7 +36305,7 @@ }, "zotero": { "name": "zotero-sciencelive", - "version": "1.0.2", + "version": "1.0.4", "dependencies": { "@nanopub/nanopub-js": "~0.1.1", "showdown": "^2.1.0", diff --git a/zotero/RELEASE.md b/zotero/RELEASE.md index bd5a2a5..6af716f 100644 --- a/zotero/RELEASE.md +++ b/zotero/RELEASE.md @@ -1,7 +1,8 @@ # How to make a new release +0. Recommended: Merge PRs/branches to main, and work off main branch for release. 1. Change the e.g. `"version": "1.0.0"` in [zotero/package.json](package.json) to a new version number, commit and push. -2. Tag the commit in github e.g. `git tag v1.0.0` then `git push origin v1.0.0`. +2. Tag the commit in github e.g. `git tag v1.0.0 && git push origin v1.0.0`. 3. Run the [Zotero Plugin Release workflow](https://github.com/ScienceLiveHub/science-live-platform/actions/workflows/zotero-release.yml) in github. 4. The plugin should automatically build and update the release as well, which means zotero users with auto-updates turned on will also get the update. 5. Check the auto-generated release notes in github, and make corrections where necessary, take a look at previous for ideas. @@ -11,5 +12,5 @@ 1. Make sure `zotero-plugin-scaffold` is the latest version in [zotero/package.json](package.json). 2. Fix any breaking changes in the latest Zotero plugin API. -3. In [zotero/addon/manifest.json](addon/manifest.json) update `strict_max_version` to latest. +3. In [zotero/addon/manifest.json](addon/manifest.json) update `strict_max_version` to latest, or use e.g. `"9.*"` to support all minor versions for v9. 4. Test dev (update to latest Zotero in dev-container: `sudo apt update && sudo apt instal zotero`) and make a new release (see above). diff --git a/zotero/addon/content/createNanopub.xhtml b/zotero/addon/content/createNanopub.xhtml index a10d661..fdc2b2a 100644 --- a/zotero/addon/content/createNanopub.xhtml +++ b/zotero/addon/content/createNanopub.xhtml @@ -33,6 +33,7 @@ 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. --> + + + diff --git a/zotero/addon/content/nanopubSearch.xhtml b/zotero/addon/content/nanopubSearch.xhtml new file mode 100644 index 0000000..4baa449 --- /dev/null +++ b/zotero/addon/content/nanopubSearch.xhtml @@ -0,0 +1,47 @@ + + + + + + + + /* Host a normal HTML page so module scripts can run reliably */ + #nanopub-search-browser { width: 100%; height: 100%; min-height: 600px; } + + + + + + +