diff --git a/src/components/DatasetDetailPage/FileTree/FileTree.tsx b/src/components/DatasetDetailPage/FileTree/FileTree.tsx index 1b5b8de..d00456b 100644 --- a/src/components/DatasetDetailPage/FileTree/FileTree.tsx +++ b/src/components/DatasetDetailPage/FileTree/FileTree.tsx @@ -14,6 +14,7 @@ type Props = { onPreview: (src: string | any, index: number, isInternal?: boolean) => void; getInternalByPath: (path: string) => { data: any; index: number } | undefined; getJsonByPath?: (path: string) => any; + highlightText?: string; }; const formatSize = (n: number) => { @@ -32,6 +33,7 @@ const FileTree: React.FC = ({ onPreview, getInternalByPath, getJsonByPath, + highlightText, }) => ( = ({ onPreview={onPreview} getInternalByPath={getInternalByPath} getJsonByPath={getJsonByPath} + highlightText={highlightText} /> // pass the handlePreview(onPreview = handlePreview) function to FileTreeRow ))} diff --git a/src/components/DatasetDetailPage/FileTree/FileTreeRow.tsx b/src/components/DatasetDetailPage/FileTree/FileTreeRow.tsx index d490104..e9ee706 100644 --- a/src/components/DatasetDetailPage/FileTree/FileTreeRow.tsx +++ b/src/components/DatasetDetailPage/FileTree/FileTreeRow.tsx @@ -14,6 +14,7 @@ import { Box, Button, Collapse, Typography } from "@mui/material"; import { Tooltip, IconButton } from "@mui/material"; import { Colors } from "design/theme"; import React, { useState } from "react"; +import { Color } from "three"; // show more / show less button for long string const LeafString: React.FC<{ value: string }> = ({ value }) => { @@ -80,11 +81,11 @@ const LeafString: React.FC<{ value: string }> = ({ value }) => { type Props = { node: TreeNode; level: number; - // src is either an external URL(string) or the internal object onPreview: (src: string | any, index: number, isInternal?: boolean) => void; getInternalByPath: (path: string) => { data: any; index: number } | undefined; getJsonByPath?: (path: string) => any; + highlightText?: string; }; // copy helper function @@ -112,6 +113,7 @@ const FileTreeRow: React.FC = ({ onPreview, getInternalByPath, getJsonByPath, + highlightText, }) => { const [open, setOpen] = useState(false); const [copied, setCopied] = useState(false); @@ -120,6 +122,34 @@ const FileTreeRow: React.FC = ({ const internal = getInternalByPath(node.path); const externalUrl = node.link?.url; + const rowRef = React.useRef(null); + // Highlight only if this row is exactly the subject folder (e.g., "sub-04") + const isSubjectFolder = + node.kind === "folder" && /^sub-[A-Za-z0-9]+$/i.test(node.name); + const isExactHit = + !!highlightText && + isSubjectFolder && + node.name.toLowerCase() === highlightText.toLowerCase(); + + React.useEffect(() => { + if (isExactHit && rowRef.current) { + rowRef.current.scrollIntoView({ behavior: "smooth", block: "center" }); + // subtle flash + // rowRef.current.animate( + // [ + // { backgroundColor: `${Colors.yellow}`, offset: 0 }, // turn yellow + // { backgroundColor: `${Colors.yellow}`, offset: 0.85 }, // stay yellow 85% of time + // { backgroundColor: "transparent", offset: 1 }, // then fade out + // ], + // { duration: 8000, easing: "ease", fill: "forwards" } + // ); + } + }, [isExactHit]); + + const rowHighlightSx = isExactHit + ? { backgroundColor: `${Colors.yellow}`, borderRadius: 4 } + : {}; + const handleCopy = async (e: React.MouseEvent) => { e.stopPropagation(); // prevent expand/ collapse from firing when click the copy button const json = getJsonByPath?.(node.path); // call getJsonByPath(node.path) @@ -136,6 +166,7 @@ const FileTreeRow: React.FC = ({ return ( <> = ({ py: 0.5, px: 1, cursor: "pointer", + ...rowHighlightSx, "&:hover": { backgroundColor: "rgba(0,0,0,0.04)" }, }} onClick={() => setOpen((o) => !o)} @@ -252,6 +284,7 @@ const FileTreeRow: React.FC = ({ onPreview={onPreview} getInternalByPath={getInternalByPath} getJsonByPath={getJsonByPath} + highlightText={highlightText} // for subject highlight /> ))} @@ -308,31 +341,6 @@ const FileTreeRow: React.FC = ({ : formatLeafValue(node.value)} ))} - - {/* {!node.link && node.value !== undefined && ( - - {node.name === "_ArrayZipData_" - ? "[compressed data]" - : formatLeafValue(node.value)} - - )} */} {/* ALWAYS show copy for files, even when no external/internal */} diff --git a/src/components/DatasetDetailPage/MetaDataPanel.tsx b/src/components/DatasetDetailPage/MetaDataPanel.tsx index b4b7573..388dca2 100644 --- a/src/components/DatasetDetailPage/MetaDataPanel.tsx +++ b/src/components/DatasetDetailPage/MetaDataPanel.tsx @@ -11,7 +11,9 @@ import { Tooltip, } from "@mui/material"; import { Colors } from "design/theme"; +import pako from "pako"; import React, { useMemo, useState } from "react"; +import { modalityValueToEnumLabel } from "utils/SearchPageFunctions/modalityLabels"; type Props = { dbViewInfo: any; @@ -63,6 +65,19 @@ const MetaDataPanel: React.FC = ({ // const [revIdx, setRevIdx] = useState(0); // const selected = revs[revIdx]; + // builds /search#query= + const buildSearchUrl = (query: Record) => { + const deflated = pako.deflate(JSON.stringify(query)); + const encoded = btoa(String.fromCharCode(...deflated)); + return `${window.location.origin}/search#query=${encoded}`; + }; + + const openSearchForModality = (mod: string) => { + const normalized = modalityValueToEnumLabel[mod] || mod; + const url = buildSearchUrl({ modality: normalized }); + window.open(url, "_blank", "noopener,noreferrer"); + }; + return ( = ({ Modalities - + + {(() => { + const mods = Array.isArray(dbViewInfo?.rows?.[0]?.value?.modality) + ? [...new Set(dbViewInfo.rows[0].value.modality as string[])] + : []; + + if (mods.length === 0) { + return ( + N/A + ); + } + + return ( + + {mods.map((m) => ( + openSearchForModality(m)} + variant="outlined" + sx={{ + "& .MuiChip-label": { + paddingX: "7px", + fontSize: "0.8rem", + }, + height: "24px", + color: Colors.white, + border: `1px solid ${Colors.orange}`, + fontWeight: "bold", + transition: "all 0.2s ease", + backgroundColor: `${Colors.orange} !important`, + "&:hover": { + backgroundColor: `${Colors.darkOrange} !important`, + color: "white", + borderColor: Colors.darkOrange, + paddingX: "8px", + fontSize: "1rem", + }, + }} + /> + ))} + + ); + })()} + {/* {dbViewInfo?.rows?.[0]?.value?.modality?.join(", ") ?? "N/A"} - + */} diff --git a/src/components/SearchPage/DatabaseCard.tsx b/src/components/SearchPage/DatabaseCard.tsx index f1525e8..025be91 100644 --- a/src/components/SearchPage/DatabaseCard.tsx +++ b/src/components/SearchPage/DatabaseCard.tsx @@ -8,8 +8,13 @@ import { Avatar, } from "@mui/material"; import { Colors } from "design/theme"; +import { useAppDispatch } from "hooks/useAppDispatch"; +import { useAppSelector } from "hooks/useAppSelector"; import React from "react"; +import { useEffect } from "react"; import { Link } from "react-router-dom"; +import { fetchDbInfo } from "redux/neurojson/neurojson.action"; +import { RootState } from "redux/store"; import RoutesEnum from "types/routes.enum"; import { modalityValueToEnumLabel } from "utils/SearchPageFunctions/modalityLabels"; @@ -32,6 +37,14 @@ const DatabaseCard: React.FC = ({ keyword, onChipClick, }) => { + const dispatch = useAppDispatch(); + const dbInfo = useAppSelector((state: RootState) => state.neurojson.dbInfo); + console.log("dbInfo", dbInfo); + useEffect(() => { + if (dbId) { + dispatch(fetchDbInfo(dbId.toLowerCase())); + } + }, [dbId, dispatch]); const databaseLink = `${RoutesEnum.DATABASES}/${dbId}`; // keyword hightlight functional component const highlightKeyword = (text: string, keyword?: string) => { @@ -182,6 +195,8 @@ const DatabaseCard: React.FC = ({ Datasets: {datasets ?? "N/A"} + {/* Datasets:{" "} + {dbInfo?.doc_count != null ? dbInfo.doc_count - 1 : "N/A"} */} diff --git a/src/components/SearchPage/SubjectCard.tsx b/src/components/SearchPage/SubjectCard.tsx index 3700086..5aeb6e0 100644 --- a/src/components/SearchPage/SubjectCard.tsx +++ b/src/components/SearchPage/SubjectCard.tsx @@ -8,7 +8,7 @@ import RoutesEnum from "types/routes.enum"; interface SubjectCardProps { dbname: string; dsname: string; - age: string; + agemin: string; subj: string; parsedJson: { key: string[]; @@ -26,7 +26,7 @@ interface SubjectCardProps { const SubjectCard: React.FC = ({ dbname, dsname, - age, + agemin, subj, parsedJson, index, @@ -34,6 +34,12 @@ const SubjectCard: React.FC = ({ }) => { const { modalities, tasks, sessions, types } = parsedJson.value; const subjectLink = `${RoutesEnum.DATABASES}/${dbname}/${dsname}`; + const canonicalSubj = /^sub-/i.test(subj) + ? subj + : `sub-${String(subj) + .replace(/^sub-/i, "") + .replace(/^0+/, "") + .padStart(2, "0")}`; // get the gender of subject const genderCode = parsedJson?.key?.[1]; @@ -46,8 +52,8 @@ const SubjectCard: React.FC = ({ // cover age string to readable format let ageDisplay = "N/A"; - if (age) { - const ageNum = parseInt(age, 10) / 100; + if (agemin) { + const ageNum = parseInt(agemin, 10) / 100; if (Number.isInteger(ageNum)) { ageDisplay = `${ageNum} years`; } else { @@ -84,8 +90,9 @@ const SubjectCard: React.FC = ({ ":hover": { textDecoration: "underline" }, }} component={Link} - to={subjectLink} - target="_blank" + // to={subjectLink} + to={`${subjectLink}?focusSubj=${encodeURIComponent(canonicalSubj)}`} + // target="_blank" > Subject: {subj}   |   Dataset: {dsname} diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index ef303a2..8e35494 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -12,6 +12,7 @@ import { Drawer, Tooltip, IconButton, + Alert, } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; @@ -54,6 +55,13 @@ const matchesKeyword = (item: RegistryItem, keyword: string) => { ); }; +// const getDbnameKey = () => { +// return new URLSearchParams(window.location.search).get("dbname"); +// }; +const getDbnameFromURL = () => + new URLSearchParams(window.location.search).get("dbname")?.trim() || ""; +// const [invalidDbNotice, setInvalidDbNotice] = useState(null); + const SearchPage: React.FC = () => { const dispatch = useAppDispatch(); const [hasSearched, setHasSearched] = useState(false); @@ -67,6 +75,8 @@ const SearchPage: React.FC = () => { const [formData, setFormData] = useState>({}); const [showSubjectFilters, setShowSubjectFilters] = useState(false); + const [showDatasetFilters, setShowDatasetFilters] = useState(true); // for dataset-level filters + const [invalidDbNotice, setInvalidDbNotice] = useState(null); const [results, setResults] = useState< any[] | { status: string; msg: string } >([]); @@ -115,6 +125,35 @@ const SearchPage: React.FC = () => { value !== "any" ); + useEffect(() => { + // If a #query=... already exists, existing effect will handle it. + if (window.location.hash.startsWith("#query=")) return; + if (!Array.isArray(registry) || registry.length === 0) return; // wait until registry is loaded + // const key = getDbnameKey(); // "openneuro" + const urlDb = getDbnameFromURL(); // e.g., "openneuro", "bfnirs", etc. + if (!urlDb) return; + // case-insensitive match against registry ids + const match = (registry as RegistryItem[]).find( + (r) => String(r.id).toLowerCase() === urlDb.toLowerCase() + ); + // if (!match) return; // unknown dbname; do nothing + + if (match) { + const initial = { database: match.id }; + // set initial form/filter state + setFormData(initial); + setAppliedFilters(initial); + setHasSearched(false); // set it to true if want to auto-run search + setShowSubjectFilters(true); // expand the subject-level section + setShowDatasetFilters(false); // collapse the dataset-level section + } else { + setInvalidDbNotice( + `Database “${urlDb}” isn’t available. Showing all databases instead.` + ); + return; + } + }, [registry]); + // parse query from url on page load useEffect(() => { const hash = window.location.hash; @@ -131,6 +170,8 @@ const SearchPage: React.FC = () => { const requestData = { ...parsed, skip: 0, limit: 50 }; setSkip(0); setHasSearched(true); + setShowSubjectFilters(true); // expand the subject-level section + setShowDatasetFilters(true); // expand the dataset-level section dispatch(fetchMetadataSearchResults(requestData)).then((res: any) => { if (res.payload) { setResults(res.payload); @@ -166,8 +207,8 @@ const SearchPage: React.FC = () => { // form UI const uiSchema = useMemo( - () => generateUiSchema(formData, showSubjectFilters), - [formData, showSubjectFilters] + () => generateUiSchema(formData, showSubjectFilters, showDatasetFilters), + [formData, showSubjectFilters, showDatasetFilters] ); // Create the "Subject-level Filters" button as a custom field @@ -190,6 +231,22 @@ const SearchPage: React.FC = () => { ), + datasetFiltersToggle: () => ( + + + + ), }; // determine the results are subject-level or dataset-level @@ -359,7 +416,7 @@ const SearchPage: React.FC = () => { const showNoResults = hasSearched && !loading && - !hasDbMatches && + // !hasDbMatches && (!hasDatasetMatches || backendEmpty); return ( { )} - {/* before submit box */} - {/* - {!hasSearched && ( - - Use the filters and click submit to search for datasets or - subjects based on metadata. - - )} - */} - {/* after submit box */} { )} - {/* {!hasSearched && ( - - Use the filters and click submit to search for{" "} - - datasets - {" "} - and{" "} - - subjects - {" "} - based on metadata. - - )} */} - + {/* if the dbname in the url is invalid */} + {invalidDbNotice && ( + + setInvalidDbNotice(null)} + sx={{ border: `1px solid ${Colors.lightGray}` }} + > + {invalidDbNotice} + + + )} + {/* suggested databases */} {registryMatches.length > 0 && ( { paginatedResults.length > 0 && paginatedResults.map((item, idx) => { try { + // console.log("item:", item); const parsedJson = JSON.parse(item.json); const globalIndex = (page - 1) * itemsPerPage + idx; @@ -838,10 +857,23 @@ const SearchPage: React.FC = () => { {/* Single place to show the red message */} {showNoResults && ( - - No results found based on your criteria. Please adjust - the filters and try again. - + + + Search Results + + + + No datasets or subjects found. Please adjust the + filters and try again. + + )} {hasSearched && diff --git a/src/pages/UpdatedDatasetDetailPage.tsx b/src/pages/UpdatedDatasetDetailPage.tsx index feb8b8e..d1f8735 100644 --- a/src/pages/UpdatedDatasetDetailPage.tsx +++ b/src/pages/UpdatedDatasetDetailPage.tsx @@ -1,5 +1,6 @@ import PreviewModal from "../components/PreviewModal"; import CloudDownloadIcon from "@mui/icons-material/CloudDownload"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import DescriptionIcon from "@mui/icons-material/Description"; import ExpandLess from "@mui/icons-material/ExpandLess"; import ExpandMore from "@mui/icons-material/ExpandMore"; @@ -12,6 +13,7 @@ import { Alert, Button, Collapse, + Snackbar, } from "@mui/material"; import FileTree from "components/DatasetDetailPage/FileTree/FileTree"; import { @@ -54,13 +56,21 @@ interface InternalDataLink { const UpdatedDatasetDetailPage: React.FC = () => { const { dbName, docId } = useParams<{ dbName: string; docId: string }>(); const navigate = useNavigate(); - // for revision const [searchParams, setSearchParams] = useSearchParams(); + // for subject highlight + const focusSubjRaw = searchParams.get("focusSubj") || undefined; + const focusSubj = !focusSubjRaw + ? undefined + : /^sub-/i.test(focusSubjRaw) + ? focusSubjRaw + : `sub-${focusSubjRaw.replace(/^0+/, "").padStart(2, "0")}`; + + // for revision const rev = searchParams.get("rev") || undefined; const handleSelectRevision = (newRev?: string | null) => { setSearchParams((prev) => { - const p = new URLSearchParams(prev); + const p = new URLSearchParams(prev); // copy of the query url if (newRev) p.set("rev", newRev); else p.delete("rev"); return p; @@ -85,20 +95,16 @@ const UpdatedDatasetDetailPage: React.FC = () => { const [isExternalExpanded, setIsExternalExpanded] = useState(true); const [jsonSize, setJsonSize] = useState(0); const [previewIndex, setPreviewIndex] = useState(0); + const [copiedToast, setCopiedToast] = useState<{ + open: boolean; + text: string; + }>({ + open: false, + text: "", + }); const aiSummary = datasetDocument?.[".datainfo"]?.AISummary ?? ""; - // useEffect(() => { - // if (!datasetDocument) { - // setJsonSize(0); - // return; - // } - // const bytes = new TextEncoder().encode( - // JSON.stringify(datasetDocument) - // ).length; - // setJsonSize(bytes); - // }, [datasetDocument]); - - const linkMap = useMemo(() => makeLinkMap(externalLinks), [externalLinks]); + const linkMap = useMemo(() => makeLinkMap(externalLinks), [externalLinks]); // => external Link Map const treeData = useMemo( () => buildTreeFromDoc(datasetDocument || {}, linkMap, ""), @@ -202,7 +208,7 @@ const UpdatedDatasetDetailPage: React.FC = () => { obj.MeshNode?.hasOwnProperty("_ArrayZipData_") && typeof obj.MeshNode["_ArrayZipData_"] === "string" ) { - console.log("path", path); + // console.log("path", path); internalLinks.push({ name: "JMesh", data: obj, @@ -254,6 +260,32 @@ const UpdatedDatasetDetailPage: React.FC = () => { return internalLinks; }; + // Build a shareable preview URL for a JSON path in this dataset + const buildPreviewUrl = (path: string) => { + const origin = window.location.origin; + const revPart = rev ? `rev=${encodeURIComponent(rev)}&` : ""; + return `${origin}/db/${dbName}/${docId}?${revPart}preview=${encodeURIComponent( + path + )}`; + }; + + // Copy helper + const copyPreviewUrl = async (path: string) => { + const url = buildPreviewUrl(path); + try { + await navigator.clipboard.writeText(url); + setCopiedToast({ open: true, text: "Preview link copied" }); + } catch { + // fallback + const ta = document.createElement("textarea"); + ta.value = url; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + document.body.removeChild(ta); + setCopiedToast({ open: true, text: "Preview link copied" }); + } + }; // useEffect(() => { // const fetchData = async () => { @@ -289,7 +321,7 @@ const UpdatedDatasetDetailPage: React.FC = () => { useEffect(() => { if (datasetDocument) { // Extract External Data & Assign `index` - console.log("datasetDocument", datasetDocument); + // console.log("datasetDocument", datasetDocument); const links = extractDataLinks(datasetDocument, "").map( (link, index) => ({ ...link, @@ -378,6 +410,13 @@ const UpdatedDatasetDetailPage: React.FC = () => { } }, [datasetDocument, docId]); + // const externalMap = React.useMemo(() => { + // const m = new Map(); + // for (const it of externalLinks) + // m.set(it.path, { url: it.url, index: it.index }); + // return m; + // }, [externalLinks]); + const [previewOpen, setPreviewOpen] = useState(false); const [previewDataKey, setPreviewDataKey] = useState(null); @@ -555,6 +594,34 @@ const UpdatedDatasetDetailPage: React.FC = () => { [datasetDocument] ); + useEffect(() => { + const p = searchParams.get("preview"); + if (!p || !datasetDocument) return; + + const previewPath = decodeURIComponent(p); + + // Try internal data first + const internal = internalMap.get(previewPath); + if (internal) { + handlePreview(internal.data, internal.index, true); + return; + } + + // Then try external data by JSON path + const external = linkMap.get(previewPath); + if (external) { + handlePreview(external.url, external.index, false); + } + }, [ + datasetDocument, + internalLinks, + externalLinks, + searchParams, + internalMap, + // externalMap, + linkMap, + ]); + const handleClosePreview = () => { setPreviewOpen(false); setPreviewDataKey(null); @@ -835,6 +902,7 @@ const UpdatedDatasetDetailPage: React.FC = () => { onPreview={handlePreview} // pass the function down to FileTree getInternalByPath={getInternalByPath} getJsonByPath={getJsonByPath} + highlightText={focusSubj} // for subject highlight /> @@ -967,26 +1035,48 @@ const UpdatedDatasetDetailPage: React.FC = () => { {link.name}{" "} {link.arraySize ? `[${link.arraySize.join("x")}]` : ""} - + + + {/* */} + )) ) : ( @@ -1120,6 +1210,28 @@ const UpdatedDatasetDetailPage: React.FC = () => { Preview )} + {/* {isPreviewable && ( + + )} */} ); @@ -1156,7 +1268,7 @@ const UpdatedDatasetDetailPage: React.FC = () => { }} > - {/* , - showSubjectFilters: boolean + showSubjectFilters: boolean, + showDatasetFilters: boolean ) => { const activeStyle = { "ui:options": { @@ -31,10 +32,58 @@ export const generateUiSchema = ( }, }; + // collapsible sections (subject-level & dataset-level) + // const subjectHiddenStyle = { + // "ui:options": { + // style: { display: showSubjectFilters ? "block" : "none" }, + // }, + // }; + + const datasetHiddenStyle = { + "ui:options": { + style: { display: showDatasetFilters ? "block" : "none" }, + }, + }; + return { - keyword: formData["keyword"] ? activeStyle : {}, - database: - formData["database"] && formData["database"] !== "any" ? activeStyle : {}, + "ui:order": [ + "dataset_filters_toggle", // button first + "database", + "keyword", + "subject_filters_toggle", + "modality", + "gender", + "age_min", + "age_max", + "sess_min", + "sess_max", + "task_min", + "task_max", + "run_min", + "run_max", + "task_name", + "type_name", + "session_name", + "run_name", + "limit", + "skip", + "*", // anything else not listed + ], + // keyword: formData["keyword"] ? activeStyle : {}, + dataset_filters_toggle: { "ui:field": "datasetFiltersToggle" }, + keyword: showDatasetFilters + ? formData["keyword"] + ? activeStyle + : {} + : datasetHiddenStyle, + // database: + // formData["database"] && formData["database"] !== "any" ? activeStyle : {}, + database: showDatasetFilters + ? formData["database"] && formData["database"] !== "any" + ? activeStyle + : {} + : datasetHiddenStyle, + // dataset: formData["dataset"] ? activeStyle : {}, // limit: formData["limit"] ? activeStyle : {}, // skip: formData["skip"] ? activeStyle : {}, diff --git a/src/utils/SearchPageFunctions/searchformSchema.ts b/src/utils/SearchPageFunctions/searchformSchema.ts index 4f1c4bd..b3e1a97 100644 --- a/src/utils/SearchPageFunctions/searchformSchema.ts +++ b/src/utils/SearchPageFunctions/searchformSchema.ts @@ -4,6 +4,10 @@ export const baseSchema: JSONSchema7 = { title: "", type: "object", properties: { + dataset_filters_toggle: { + type: "null", + title: "Dataset Filters", + }, keyword: { title: "Search keyword", type: "string",