Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
201 changes: 201 additions & 0 deletions frontend/src/hooks/use-nanopub-search.ts
Original file line number Diff line number Diff line change
@@ -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<FeedTemplateKey>;
/** 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<SearchResult[] | null>(
null,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 };
}
45 changes: 45 additions & 0 deletions frontend/src/lib/external-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Utility for opening external URLs that works in both browser and Zotero contexts.
*
* In a normal browser, `<a target="_blank">` 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 `<a>` elements that opens the link via the
* provided callback (or falls back to `window.open`).
*
* Usage:
* ```tsx
* <a href={url} onClick={makeExternalLinkHandler(onOpenExternalUrl)} ...>
* ```
*/
export function makeExternalLinkHandler(
onOpenExternalUrl?: OpenExternalUrlFn,
): (e: React.MouseEvent<HTMLAnchorElement>) => void {
const opener = onOpenExternalUrl ?? defaultOpenExternalUrl;
return (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
e.stopPropagation();
const url = e.currentTarget.href;
if (url) {
opener(url);
}
};
}
2 changes: 1 addition & 1 deletion frontend/src/pages/np/GeoMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,6 +24,7 @@ import {
type GeoLocation,
type MapBounds,
} from "./components/geo";
import SearchResultList from "./components/search/SearchResultList";

// ---------------------------------------------------------------------------
// Main Page Component
Expand Down
Loading
Loading