diff --git a/.cursor-updates b/.cursor-updates new file mode 100644 index 0000000..56e7441 --- /dev/null +++ b/.cursor-updates @@ -0,0 +1,5 @@ +# Cursor Updates + +- Added discovery card below Catalog mental health card with Harmony logo and discovery chips for topics and instruments that link to the discovery app (layout matches Catalog card with logo on one side, content on the other) +- Added Harmony logo above match percentage in each match results card with tooltip linking to discovery search using query parameter with both question texts +- Updated URL generation to use getCurrentDomain() and getDiscoveryNextPath() utility functions for proper localhost (port 3222) and production (/search/) URL handling diff --git a/src/components/AppBar.js b/src/components/AppBar.js index 5355cc3..906d8f8 100644 --- a/src/components/AppBar.js +++ b/src/components/AppBar.js @@ -23,6 +23,7 @@ import GoogleIcon from "@mui/icons-material/Google"; import GitHubIcon from "@mui/icons-material/GitHub"; import TwitterIcon from "@mui/icons-material/Twitter"; import { useData } from "../contexts/DataContext"; +import ComingSoonDialog from "./ComingSoonDialog"; const settings = ["My Harmony", "Logout"]; const SettingsIcons = { @@ -34,6 +35,8 @@ function HarmonyAppBar() { const [apiVersion, setApiVersion] = React.useState(null); const [allModels, setAllModels] = React.useState(); const [error, setError] = React.useState(null); + const [comingSoonOpen, setComingSoonOpen] = React.useState(false); + const [comingSoonFeature, setComingSoonFeature] = React.useState(""); const { currentUser, logout, @@ -75,6 +78,11 @@ function HarmonyAppBar() { const handleUserMenuClick = (menuItem) => { switch (menuItem) { + case "My Harmony": + handleCloseUserMenu(); + setComingSoonFeature("My Harmony"); + setComingSoonOpen(true); + break; case "Logout": handleCloseUserMenu(); console.log("logging out"); @@ -267,6 +275,11 @@ function HarmonyAppBar() { + setComingSoonOpen(false)} + featureName={comingSoonFeature} + /> ); } diff --git a/src/components/HarmonySidebar.js b/src/components/HarmonySidebar.js index d106208..78579dd 100644 --- a/src/components/HarmonySidebar.js +++ b/src/components/HarmonySidebar.js @@ -147,6 +147,11 @@ export default function HarmonySidebar() { const handleUserMenuClick = (menuItem) => { switch (menuItem) { + case "My Harmony": + handleCloseUserMenu(); + setComingSoonFeature("My Harmony"); + setComingSoonOpen(true); + break; case "Logout": handleCloseUserMenu(); console.log("logging out"); diff --git a/src/components/MatchUnit.js b/src/components/MatchUnit.js index 44191f3..547e759 100644 --- a/src/components/MatchUnit.js +++ b/src/components/MatchUnit.js @@ -7,6 +7,8 @@ import { Menu, MenuItem, ListItemIcon, + Tooltip, + Link, } from "@mui/material"; import "react-circular-progressbar/dist/styles.css"; import MatchCircle from "./MatchCircle"; @@ -19,6 +21,55 @@ const MatchUnit = ({ Q1, Q2, percentage, matchUnitMenuAction, selected }) => { // const [isFlipped, setIsFlipped] = useState(false); const [menuElement, setMenuElement] = useState(null); + // Get current domain for dynamic links + const getCurrentDomain = () => { + if (typeof window !== "undefined") { + // Handle local development with different ports + if ( + window.location.hostname === "localhost" || + window.location.hostname === "127.0.0.1" + ) { + // React app runs on port 3000, DiscoveryNext runs on port 3222 + return "http://localhost:3222"; + } + return window.location.origin; + } + return "https://harmonydata.ac.uk"; // fallback for SSR + }; + + // Get the correct path for DiscoveryNext links + const getDiscoveryNextPath = (path) => { + if (typeof window !== "undefined") { + // Handle local development - DiscoveryNext is on root + if ( + window.location.hostname === "localhost" || + window.location.hostname === "127.0.0.1" + ) { + return path; + } + // Production - DiscoveryNext is under /search + return `/search${path}`; + } + return `/search${path}`; // fallback for SSR + }; + + // Build discovery URL with both question texts as query + const getDiscoveryUrl = () => { + const params = new URLSearchParams(); + + // Combine both question texts for the query + const queryText = [Q1.question_text, Q2.question_text] + .filter(Boolean) + .join(" "); + + if (queryText) { + params.set("query", queryText); + } + + const queryString = params.toString(); + return `${getCurrentDomain()}${getDiscoveryNextPath("/")}${queryString ? `?${queryString}` : ""}`; + }; + const openMenu = (event) => { event.stopPropagation(); setMenuElement(event.currentTarget); @@ -72,7 +123,41 @@ const MatchUnit = ({ Q1, Q2, percentage, matchUnitMenuAction, selected }) => { {Q1.instrument && Q1.instrument.name} - Q{Q1.question_no} - + + + { + e.stopPropagation(); + }} + sx={{ + display: "flex", + alignItems: "center", + justifyContent: "center", + textDecoration: "none", + }} + > + Harmony Logo + + { + if (typeof window !== "undefined") { + // Handle local development with different ports + if ( + window.location.hostname === "localhost" || + window.location.hostname === "127.0.0.1" + ) { + // React app runs on port 3000, DiscoveryNext runs on port 3222 + return "http://localhost:3222"; + } + return window.location.origin; + } + return "https://harmonydata.ac.uk"; // fallback for SSR + }; + + // Get the correct path for DiscoveryNext links + const getDiscoveryNextPath = (path) => { + if (typeof window !== "undefined") { + // Handle local development - DiscoveryNext is on root + if ( + window.location.hostname === "localhost" || + window.location.hostname === "127.0.0.1" + ) { + return path; // e.g., "/" becomes "/" + } + // Production - DiscoveryNext is under /search + return `/search${path}`; // e.g., "/" becomes "/search/" + } + return `/search${path}`; // fallback for SSR + }; + const getQuestion = useCallback( (qidx) => { return apiData.instruments @@ -241,6 +278,30 @@ export default function Results({ .flat() ), ]); + + // Extract unique instrument names + const instruments = [ + ...new Set( + cm + .map((m) => { + const q = getQuestion(m.qi); + const mq = getQuestion(m.mqi); + let inst = []; + if (q.instrument && q.instrument.name) { + inst.push(q.instrument.name); + } + if (mq.instrument && mq.instrument.name) { + inst.push(mq.instrument.name); + } + return inst; + }) + .flat() + .filter(Boolean) + ), + ]; + setUniqueInstruments(instruments); + } else { + setUniqueInstruments([]); } }, [resultsOptions, apiData, setComputedMatches, getQuestion]); @@ -395,6 +456,95 @@ export default function Results({ )} + {/* Discovery Card - Harmony Discovery Links */} + {(topics.length > 0 || + (SHOW_INSTRUMENTS_CHIP && uniqueInstruments.length > 0)) && ( + + + Harmony Logo + + + + Discover: + + + {topics.length > 0 && ( + `topics=${encodeURIComponent(t)}`).join("&")}`} + target="HarmonyDiscovery" + clickable + sx={{ + cursor: "pointer", + "&:hover": { + backgroundColor: "action.hover", + }, + }} + onClick={(e) => { + e.stopPropagation(); + }} + /> + )} + {SHOW_INSTRUMENTS_CHIP && uniqueInstruments.length > 0 && ( + `instruments=${encodeURIComponent(i)}`).join("&")}`} + target="HarmonyDiscovery" + clickable + sx={{ + cursor: "pointer", + "&:hover": { + backgroundColor: "action.hover", + }, + }} + onClick={(e) => { + e.stopPropagation(); + }} + /> + )} + + + Placeholder + + + + + )} {computedMatches && computedMatches.slice(0, 100).map((i) => { return ( diff --git a/src/components/Upload.js b/src/components/Upload.js index 0a3a1d6..d3a8ba9 100644 --- a/src/components/Upload.js +++ b/src/components/Upload.js @@ -217,34 +217,62 @@ export default function Upload({ ); }); Promise.all(frp) - .then((allFiles) => { - toast.promise( - new Promise((resolve, reject) => { - parse(allFiles) - .then((data) => { - const newFileInfos = [...fileInfos]; - // Load each returned file / instrument in the data - data.forEach((instrument) => { - newFileInfos.push(instrument); - }); - setFileInfos(newFileInfos); - resolve(true); - }) - .catch((e) => { - console.log(e); - setParseError(true); - reject("Parse Error"); - }) - .finally((_) => { - syncFileInfos(); + .then(async (allFiles) => { + // Parse ONE file per API call, in series (issue #61). A single + // /text/parse call carrying many large PDFs times out: Tika can take + // well over a minute per big or scanned PDF, and the old 15s timeout + // with 3 retries then re-sent the whole batch while the server was + // still working on the first attempt. One file per call keeps each + // request inside the proxy limits, shows progress, and lets the good + // files land even if one bad scan fails. The API caches parsed files + // by content, so re-uploading an already-parsed file is instant. + const progress = (i) => + allFiles.length === 1 + ? `Parsing ${allFiles[0].file_name} - this may take a while` + : `Parsing ${i + 1} of ${allFiles.length}: ${allFiles[i].file_name} - this may take a while`; + const toastId = toast.loading(progress(0)); + const newFileInfos = [...fileInfos]; + const failed = []; + let parsedCount = 0; + for (let i = 0; i < allFiles.length; i++) { + toast.update(toastId, { render: progress(i) }); + try { + const data = await parse([allFiles[i]]); + if (Array.isArray(data) && data.length) { + data.forEach((instrument) => { + newFileInfos.push(instrument); }); - }), - { - pending: "Parsing files - this may take a while", - success: "Success!", - error: "Something went wrong - please try again", + // Show each file as soon as it lands rather than at the end + setFileInfos([...newFileInfos]); + parsedCount++; + } else { + failed.push(`${allFiles[i].file_name} (no questions found)`); + } + } catch (e) { + console.log(e); + failed.push(allFiles[i].file_name); } - ); + } + syncFileInfos(); + if (failed.length === 0) { + toast.update(toastId, { + render: "Success!", + type: "success", + isLoading: false, + autoClose: 3000, + }); + } else { + setParseError(true); + toast.update(toastId, { + render: + parsedCount === 0 + ? "Something went wrong - please try again" + : `Parsed ${parsedCount} of ${allFiles.length} files - could not parse ${failed.join(", ")}`, + type: parsedCount === 0 ? "error" : "warning", + isLoading: false, + autoClose: 8000, + }); + } }) .catch((e) => { console.log(e); diff --git a/src/contexts/DataContext.js b/src/contexts/DataContext.js index 65ff6e0..a861be2 100644 --- a/src/contexts/DataContext.js +++ b/src/contexts/DataContext.js @@ -26,9 +26,13 @@ export function DataProvider({ children }) { framework: "huggingface", model: "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2", }); - const retryablePostData = ({ url = "", data = {}, timeout = 8000 }) => { + const retryablePostData = ({ + url = "", + data = {}, + timeout = 8000, + retries = 3, + }) => { return new Promise(async (resolve, reject) => { - var retries = 3; var response; while (retries > 0) { try { @@ -125,11 +129,16 @@ export function DataProvider({ children }) { }); }; - const parse = (allFiles) => { + // Parse is called with ONE file at a time by Upload.js (see issue #61). + // Tika can take well over a minute on a large or scanned PDF, so the + // per-call timeout is generous and there is no blind retry: re-sending a + // big PDF that just timed out only piles more work onto the server. + const parse = (allFiles, timeout = 180000) => { return retryablePostData({ url: process.env.REACT_APP_API_PARSE, data: allFiles, - timeout: 15000, + timeout: timeout, + retries: 1, }); }; const match = useCallback(