From 96d8381dcb64a546ad2751896ff9a9933b945b57 Mon Sep 17 00:00:00 2001 From: Ahmon Date: Mon, 21 Apr 2025 21:23:35 -0700 Subject: [PATCH 1/3] [add] Refactor AgreementViewer layout, add caching, and enable immediate chat - Implement draggable divider and hideable majors column in AgreementViewerPage. - Add localStorage caching for majors list and PDF image filenames. - Enable ChatInterface immediately on AgreementViewerPage load. - Remove major selection from CollegeTransferForm and update navigation. - Adjust layout calculations and fix closure issue in resize handler. - Update Python and JS dependencies (openai, dotenv, etc.). --- .../college_transfer_API.py | 1 - requirements.txt | 6 +- src/App.css | 1 - src/components/AgreementViewerPage.jsx | 406 ++++++++++++++---- src/components/ChatInterface.jsx | 5 +- src/components/CollegeTransferForm.jsx | 38 +- src/services/api.js | 3 +- 7 files changed, 333 insertions(+), 127 deletions(-) diff --git a/backend/college_transfer_ai/college_transfer_API.py b/backend/college_transfer_ai/college_transfer_API.py index 16a4a56..770ff7c 100644 --- a/backend/college_transfer_ai/college_transfer_API.py +++ b/backend/college_transfer_ai/college_transfer_API.py @@ -9,7 +9,6 @@ class CollegeTransferAPI: def __init__(self): self.base_url = "https://assist.org/api/" - def get_academic_years(self): url = self.base_url + "AcademicYears" response = requests.get(url) diff --git a/requirements.txt b/requirements.txt index 8b3d6b9..a913c63 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,8 @@ pymongo playwright jinja2 pytest -PyMuPDF \ No newline at end of file +PyMuPDF +openai +dotenv +react-router-dom +react \ No newline at end of file diff --git a/src/App.css b/src/App.css index 38ec24c..6e2e23c 100644 --- a/src/App.css +++ b/src/App.css @@ -10,7 +10,6 @@ body { /* Container for centering content */ #root > div { /* Target the main div rendered by React */ - max-width: 960px; /* Max width similar to assist.org content area */ margin: 20px auto; /* Center the container */ padding: 20px; background-color: #fff; /* White background for content area */ diff --git a/src/components/AgreementViewerPage.jsx b/src/components/AgreementViewerPage.jsx index e543c60..4fa87a7 100644 --- a/src/components/AgreementViewerPage.jsx +++ b/src/components/AgreementViewerPage.jsx @@ -1,50 +1,111 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { useParams, Link } from 'react-router-dom'; import { fetchData } from '../services/api'; import PdfViewer from './PdfViewer'; -import ChatInterface from './ChatInterface'; // Import ChatInterface +import ChatInterface from './ChatInterface'; import '../App.css'; +// Define minColWidth and dividerWidth as constants outside the component +const minColWidth = 150; +const dividerWidth = 1; +const fixedMajorsWidth = 300; // Revert to fixed width for majors + function AgreementViewerPage() { const { sendingId, receivingId, yearId } = useParams(); - // State for this page + // --- State for resizing --- + // Only need state for the chat column width now + const [chatColumnWidth, setChatColumnWidth] = useState(400); + const isResizingRef = useRef(false); + // Only need one divider ref now + const dividerRef = useRef(null); // Ref for the divider between Chat and PDF + const containerRef = useRef(null); // Ref for the main container + + // --- State for Majors Column Visibility --- + const [isMajorsVisible, setIsMajorsVisible] = useState(true); + // --- Ref to hold the latest visibility state for the event listener --- + const isMajorsVisibleRef = useRef(isMajorsVisible); + + // --- Effect to update the ref whenever the state changes --- + useEffect(() => { + isMajorsVisibleRef.current = isMajorsVisible; + }, [isMajorsVisible]); + + // --- Existing State --- const [majors, setMajors] = useState({}); const [isLoadingMajors, setIsLoadingMajors] = useState(true); - const [error, setError] = useState(null); // General/Major loading error - const [pdfError, setPdfError] = useState(null); // Specific PDF loading error + const [error, setError] = useState(null); + const [pdfError, setPdfError] = useState(null); const [selectedMajorKey, setSelectedMajorKey] = useState(null); - const [selectedMajorName, setSelectedMajorName] = useState(''); // Store name for chat context + const [selectedMajorName, setSelectedMajorName] = useState(''); const [selectedPdfFilename, setSelectedPdfFilename] = useState(null); - const [imageFilenames, setImageFilenames] = useState([]); // State for image filenames - const [isLoadingPdf, setIsLoadingPdf] = useState(false); // Loading PDF info + images + const [imageFilenames, setImageFilenames] = useState([]); + const [isLoadingPdf, setIsLoadingPdf] = useState(false); const [majorSearchTerm, setMajorSearchTerm] = useState(''); - // Fetch majors + // --- Effect for Fetching Majors (with Caching) --- useEffect(() => { - // ... existing useEffect logic to fetch majors ... if (!sendingId || !receivingId || !yearId) { setError("Required institution or year information is missing in URL."); setIsLoadingMajors(false); return; } + + // Generate a unique cache key for this combination + const cacheKey = `majors-${sendingId}-${receivingId}-${yearId}`; + let cachedMajors = null; + + // 1. Try loading from localStorage + try { + const cachedData = localStorage.getItem(cacheKey); + if (cachedData) { + cachedMajors = JSON.parse(cachedData); + console.log("Loaded majors from cache:", cacheKey); + setMajors(cachedMajors); + setIsLoadingMajors(false); + setError(null); + // Optional: You could still fetch in the background here to update the cache + // if you want fresher data without blocking the initial load. + return; // Exit early if loaded from cache + } + } catch (e) { + console.error("Failed to read or parse majors cache:", e); + localStorage.removeItem(cacheKey); // Clear potentially corrupted cache entry + } + + // 2. If not cached or cache failed, fetch from API + console.log("Fetching majors from API:", cacheKey); setIsLoadingMajors(true); setError(null); fetchData(`majors?sendingInstitutionId=${sendingId}&receivingInstitutionId=${receivingId}&academicYearId=${yearId}&categoryCode=major`) .then(data => { - if (Object.keys(data).length === 0) { + if (data && Object.keys(data).length === 0) { // Check if data is not null/undefined before checking keys setError("No majors found for the selected combination."); + setMajors({}); + } else if (data) { // Check if data is not null/undefined + setMajors(data); + try { + localStorage.setItem(cacheKey, JSON.stringify(data)); + console.log("Saved majors to cache:", cacheKey); + } catch (e) { + console.error("Failed to save majors to cache:", e); + } + } else { + // Handle cases where fetchData might return null or undefined unexpectedly + setError("Received unexpected empty response when fetching majors."); + setMajors({}); } - setMajors(data); }) .catch(err => { console.error("Error fetching majors:", err); setError(`Failed to load majors: ${err.message}`); + setMajors({}); }) .finally(() => { setIsLoadingMajors(false); }); - }, [sendingId, receivingId, yearId]); + + }, [sendingId, receivingId, yearId]); // Re-run effect if IDs change // Fetch PDF filename AND image filenames when major is selected const handleMajorSelect = async (majorKey, majorName) => { @@ -61,21 +122,56 @@ function AgreementViewerPage() { try { // 1. Get PDF Filename const agreementData = await fetchData(`articulation-agreement?key=${majorKey}`); - if (agreementData.pdf_filename) { + if (agreementData && agreementData.pdf_filename) { const pdfFilename = agreementData.pdf_filename; setSelectedPdfFilename(pdfFilename); // Set filename for context - // 2. Get Image Filenames for the PDF - const imageData = await fetchData(`pdf-images/${pdfFilename}`); - if (imageData.image_filenames) { - setImageFilenames(imageData.image_filenames); - } else { - throw new Error(imageData.error || 'Failed to load image list for PDF'); + // --- Image Caching Logic --- + const imageCacheKey = `pdf-images-${pdfFilename}`; + let fetchedFromCache = false; + + // 2a. Try loading images from localStorage + try { + const cachedImageData = localStorage.getItem(imageCacheKey); + if (cachedImageData) { + const parsedImageData = JSON.parse(cachedImageData); + if (parsedImageData && parsedImageData.image_filenames) { + console.log("Loaded images from cache:", imageCacheKey); + setImageFilenames(parsedImageData.image_filenames); + fetchedFromCache = true; // Mark as fetched from cache + } else { + console.warn("Cached image data invalid, removing:", imageCacheKey); + localStorage.removeItem(imageCacheKey); + } + } + } catch (e) { + console.error("Failed to read or parse images cache:", e); + localStorage.removeItem(imageCacheKey); // Clear potentially corrupted cache entry + } + // --- End Image Caching Logic --- + + // 2b. Fetch images from API if not loaded from cache + if (!fetchedFromCache) { + console.log("Fetching images from API:", imageCacheKey); + const imageData = await fetchData(`pdf-images/${pdfFilename}`); + if (imageData && imageData.image_filenames) { + setImageFilenames(imageData.image_filenames); + // 3. Save successful image fetch to localStorage + try { + localStorage.setItem(imageCacheKey, JSON.stringify(imageData)); + console.log("Saved images to cache:", imageCacheKey); + } catch (e) { + console.error("Failed to save images to cache:", e); + } + } else { + // Handle case where API returns error or no filenames + throw new Error(imageData?.error || 'Failed to load image list for PDF'); + } } - } else if (agreementData.error) { + } else if (agreementData && agreementData.error) { throw new Error(`Agreement Error: ${agreementData.error}`); } else { - throw new Error('Received unexpected data when fetching agreement.'); + throw new Error('Received unexpected data or no PDF filename when fetching agreement.'); } } catch (err) { console.error("Error fetching agreement or images:", err); @@ -89,84 +185,222 @@ function AgreementViewerPage() { // Filter majors based on search term const filteredMajors = useMemo(() => { - // ... existing useMemo logic ... const lowerCaseSearchTerm = majorSearchTerm.toLowerCase(); + // Ensure majors is an object before trying to get entries + if (typeof majors !== 'object' || majors === null) { + return []; + } return Object.entries(majors).filter(([name]) => name.toLowerCase().includes(lowerCaseSearchTerm) ); }, [majors, majorSearchTerm]); + // --- Resizing Logic (Simplified for one divider) --- + const handleMouseDown = useCallback((e) => { + e.preventDefault(); + isResizingRef.current = true; + // Add the *same* memoized handleMouseMove function as the listener + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }, []); // handleMouseMove is stable now, so no need to list it if it doesn't change + + const handleMouseMove = useCallback((e) => { // Keep useCallback, but dependencies change + if (!isResizingRef.current || !containerRef.current) { + return; + } + + const containerRect = containerRef.current.getBoundingClientRect(); + const mouseX = e.clientX; + const containerLeft = containerRect.left; + const totalWidth = containerRect.width; + const gapWidth = 16; // Assumed 1em = 16px + + // --- Read the *current* visibility from the ref --- + const currentVisibility = isMajorsVisibleRef.current; + + // Calculate the starting position of the chat column + const majorsEffectiveWidth = currentVisibility ? fixedMajorsWidth : 0; + const gap1EffectiveWidth = currentVisibility ? gapWidth : 0; // Gap between majors and chat + const chatStartOffset = majorsEffectiveWidth + gap1EffectiveWidth; + + // Calculate desired chat width based on mouse position relative to chat start + let newChatWidth = mouseX - containerLeft - chatStartOffset; + + // Constraints: ensure chat and PDF columns have minimum width + const maxChatWidth = totalWidth - chatStartOffset - minColWidth - gapWidth - dividerWidth; + newChatWidth = Math.max(minColWidth, Math.min(newChatWidth, maxChatWidth)); + + setChatColumnWidth(newChatWidth); + + // --- Remove isMajorsVisible from dependencies, rely on the ref --- + }, []); // Empty dependency array is okay now because we use the ref + + const handleMouseUp = useCallback(() => { + if (isResizingRef.current) { + isResizingRef.current = false; + // Remove the *same* handleMouseMove function instance + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + } + // Ensure handleMouseMove is included if it's used inside, though it's stable now + }, [handleMouseMove]); + + // Cleanup listeners + useEffect(() => { + // Define cleanup using the memoized handleMouseMove + const currentHandleMouseMove = handleMouseMove; + const currentHandleMouseUp = handleMouseUp; + return () => { + window.removeEventListener('mousemove', currentHandleMouseMove); + window.removeEventListener('mouseup', currentHandleMouseUp); + }; + }, [handleMouseMove, handleMouseUp]); // Depend on the memoized functions + + // --- Toggle Majors Visibility --- + const toggleMajorsVisibility = () => { + const gapWidth = 16; // Ensure this matches the gap used in styles/calculations + + // Use the functional update form to get the latest states + setIsMajorsVisible(prevVisible => { + const nextVisible = !prevVisible; + + // Adjust chat width based on the *change* in visibility + setChatColumnWidth(prevChatWidth => { + if (nextVisible === false) { // Majors are being hidden + // Increase chat width to absorb the space + return prevChatWidth + fixedMajorsWidth + gapWidth; + } else { // Majors are being shown + // Decrease chat width, ensuring it doesn't go below min width + const newWidth = prevChatWidth - fixedMajorsWidth - gapWidth; + return Math.max(minColWidth, newWidth); + } + }); + + return nextVisible; // Return the new visibility state + }); + }; + + + // Calculate effective widths for flex styling based on visibility + const currentMajorsFlexBasis = isMajorsVisible ? `${fixedMajorsWidth}px` : '0px'; + const currentChatFlexBasis = `${chatColumnWidth}px`; + return ( - // Main container using Flexbox, full height, 3 columns -
- - {/* Left Column (Majors List) */} -
- Back to Form -

Select Major

- setMajorSearchTerm(e.target.value)} - style={{ marginBottom: '0.5em', padding: '8px', border: '1px solid #ccc' }} - /> - {error &&
Error: {error}
} - {isLoadingMajors &&

Loading available majors...

} - {/* Scrollable Major List */} - {!isLoadingMajors && filteredMajors.length > 0 && ( -
- {filteredMajors.map(([name, key]) => ( -
handleMajorSelect(key, name)} - style={{ - padding: '8px 12px', - cursor: 'pointer', - borderBottom: '1px solid #eee', - backgroundColor: selectedMajorKey === key ? '#e0e0e0' : 'transparent', - fontWeight: selectedMajorKey === key ? 'bold' : 'normal' - }} - className="major-list-item" - > - {name} - {selectedMajorKey === key && isLoadingPdf && (Loading...)} -
- ))} -
- )} - {/* ... other messages for no majors found ... */} - {!isLoadingMajors && filteredMajors.length === 0 && Object.keys(majors).length > 0 && ( -

No majors match your search.

- )} - {!isLoadingMajors && Object.keys(majors).length === 0 && !error && ( -

No majors found.

- )} + <> + {/* Button Bar */} +
+ + Back to Form
- {/* Middle Column (Chat Interface) - Conditionally Rendered */} -
- {selectedPdfFilename ? ( - - ) : ( -
- Select a major to enable chat. + {/* Main container using Flexbox */} +
+ + {/* Left Column (Majors List) - Conditionally Rendered, Fixed Width */} + {isMajorsVisible && ( +
+ {/* Content of Majors Column */} +

Select Major

+ setMajorSearchTerm(e.target.value)} + style={{ marginBottom: '0.5em', padding: '8px', border: '1px solid #ccc' }} + /> + {error &&
Error: {error}
} + {isLoadingMajors &&

Loading available majors...

} + {!isLoadingMajors && filteredMajors.length > 0 && ( +
+ {filteredMajors.map(([name, key]) => ( +
handleMajorSelect(key, name)} + style={{ + padding: '8px 12px', + cursor: 'pointer', + borderBottom: '1px solid #eee', + backgroundColor: selectedMajorKey === key ? '#e0e0e0' : 'transparent', + fontWeight: selectedMajorKey === key ? 'bold' : 'normal' + }} + className="major-list-item" + > + {name} + {selectedMajorKey === key && isLoadingPdf && (Loading...)} +
+ ))} +
+ )} + {!isLoadingMajors && filteredMajors.length === 0 && Object.keys(majors).length > 0 && ( +

No majors match your search.

+ )} + {!isLoadingMajors && Object.keys(majors).length === 0 && !error && ( +

No majors found.

+ )}
)} -
- {/* Right Column (PDF Viewer) */} -
- {/* Pass image filenames and loading/error state */} - + {/* Render ChatInterface unconditionally */} + +
+ + {/* --- Draggable Divider 2 (Now the only one) --- */} +
-
+ {/* --- End Divider --- */} -
+ + {/* Right Column (PDF Viewer) - Takes Remaining Space */} +
+ +
+ +
+ ); } diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx index e5c6001..170634c 100644 --- a/src/components/ChatInterface.jsx +++ b/src/components/ChatInterface.jsx @@ -9,7 +9,6 @@ function ChatInterface({ imageFilenames, selectedMajorName }) { // Clear chat when imageFilenames change (new agreement selected) useEffect(() => { - setMessages([{ type: 'system', text: `Chatting about: ${selectedMajorName || 'Agreement'}` }]); setUserInput(''); setChatError(null); }, [imageFilenames, selectedMajorName]); @@ -83,8 +82,8 @@ function ChatInterface({ imageFilenames, selectedMajorName }) { type="text" value={userInput} onChange={(e) => setUserInput(e.target.value)} - onKeyPress={(e) => e.key === 'Enter' && handleSend()} - placeholder="Ask about the agreement..." + onKeyDown={(e) => e.key === 'Enter' && handleSend()} + placeholder="Select a major..." style={{ flexGrow: 1, marginRight: '10px', padding: '8px' }} disabled={isLoading || !imageFilenames || imageFilenames.length === 0} /> diff --git a/src/components/CollegeTransferForm.jsx b/src/components/CollegeTransferForm.jsx index a918d1f..983f519 100644 --- a/src/components/CollegeTransferForm.jsx +++ b/src/components/CollegeTransferForm.jsx @@ -10,33 +10,27 @@ const CollegeTransferForm = () => { const [institutions, setInstitutions] = useState({}); const [receivingInstitutions, setReceivingInstitutions] = useState({}); const [academicYears, setAcademicYears] = useState({}); - // REMOVED: const [majors, setMajors] = useState({}); // --- State for input values and selections --- const [sendingInputValue, setSendingInputValue] = useState(''); const [receivingInputValue, setReceivingInputValue] = useState(''); const [yearInputValue, setYearInputValue] = useState(''); - // REMOVED: const [majorInputValue, setMajorInputValue] = useState(''); const [selectedSendingId, setSelectedSendingId] = useState(null); const [selectedReceivingId, setSelectedReceivingId] = useState(null); const [selectedYearId, setSelectedYearId] = useState(null); - // REMOVED: const [selectedMajorKey, setSelectedMajorKey] = useState(null); // --- State for dropdown visibility and filtered options --- const [showSendingDropdown, setShowSendingDropdown] = useState(false); const [showReceivingDropdown, setShowReceivingDropdown] = useState(false); const [showYearDropdown, setShowYearDropdown] = useState(false); - // REMOVED: const [showMajorDropdown, setShowMajorDropdown] = useState(false); const [filteredInstitutions, setFilteredInstitutions] = useState([]); const [filteredReceiving, setFilteredReceiving] = useState([]); const [filteredYears, setFilteredYears] = useState([]); - // REMOVED: const [filteredMajors, setFilteredMajors] = useState([]); // --- State for loading and results --- const [isLoading] = useState(false); // Keep for initial loads if needed - const [resultMessage, setResultMessage] = useState('Select institutions and year to view available majors.'); // Updated message const [error, setError] = useState(null); // --- Helper Functions --- @@ -44,22 +38,16 @@ const CollegeTransferForm = () => { setSendingInputValue(''); setReceivingInputValue(''); setYearInputValue(''); - // REMOVED: setMajorInputValue(''); setSelectedSendingId(null); setSelectedReceivingId(null); setSelectedYearId(null); - // REMOVED: setSelectedMajorKey(null); setReceivingInstitutions({}); - // REMOVED: setMajors({}); setFilteredInstitutions([]); setFilteredReceiving([]); setFilteredYears([]); - // REMOVED: setFilteredMajors([]); setShowSendingDropdown(false); setShowReceivingDropdown(false); setShowYearDropdown(false); - // REMOVED: setShowMajorDropdown(false); - setResultMessage('Select institutions and year to view available majors.'); // Updated message setError(null); }, []); @@ -79,11 +67,6 @@ const CollegeTransferForm = () => { setSelectedReceivingId(null); setReceivingInstitutions({}); setFilteredReceiving([]); - // Clear major related states if they were previously set (good practice after refactor) - // REMOVED: setMajorInputValue(''); - // REMOVED: setSelectedMajorKey(null); - // REMOVED: setMajors({}); - // REMOVED: setFilteredMajors([]); if (selectedSendingId) { fetchData(`receiving-institutions?sendingInstitutionId=${selectedSendingId}`) @@ -92,8 +75,6 @@ const CollegeTransferForm = () => { } }, [selectedSendingId]); - // REMOVED: useEffect hook that fetched majors - // --- Effects for Filtering Dropdowns --- const filter = useCallback( ((value, data, setFiltered, setShowDropdown) => { @@ -141,8 +122,6 @@ const CollegeTransferForm = () => { } }, [yearInputValue, academicYears, filter]); - // REMOVED: useEffect hook for filtering majors - // --- Event Handlers --- const handleInputChange = (e, setInputValue) => { setInputValue(e.target.value); @@ -176,7 +155,6 @@ const CollegeTransferForm = () => { } }; - // MODIFIED: Renamed and changed logic const handleViewMajors = () => { // Keep name or rename to handleViewAgreements if (!selectedSendingId || !selectedReceivingId || !selectedYearId) { setError("Please select sending institution, receiving institution, and academic year first."); @@ -208,13 +186,13 @@ const CollegeTransferForm = () => { // --- Component JSX --- return ( -
+

College Transfer AI

{error &&
Error: {error}
} {/* Sending Institution */}
- + { setFilteredInstitutions(allOptions); setShowSendingDropdown(true); }} - onBlur={() => setShowSendingDropdown(false)} // Delay to allow click + onBlur={() => setShowSendingDropdown(false)} autoComplete="off" /> {renderDropdown(filteredInstitutions, showSendingDropdown, 'sending')} @@ -234,7 +212,7 @@ const CollegeTransferForm = () => { {/* Receiving Institution */}
- + { {/* Academic Year */}
- + { > {isLoading ? 'Loading...' : 'View Agreements'} {/* Updated text */} - - {/* Result message area (optional, could be removed or kept for general status) */} -
-

Status:

-
{resultMessage}
-
); }; diff --git a/src/services/api.js b/src/services/api.js index fbc7435..7216e9c 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -6,6 +6,7 @@ * @returns {Promise} - A promise that resolves with the JSON data or null for empty responses. * @throws {Error} - Throws an error if the fetch fails or response is not ok. */ + export async function fetchData(endpoint, options = {}) { // Construct the full URL, always prepending /api/ // Ensure no double slashes if endpoint accidentally starts with one @@ -13,8 +14,6 @@ export async function fetchData(endpoint, options = {}) { const url = `/api/${cleanEndpoint}`; // Use relative path for the proxy try { - console.log(`Fetching data from: ${url} with options:`, options); // Log URL and options - // *** Pass the options object as the second argument to fetch *** const response = await fetch(url, options); From 12c3a34faa5f95e3b837c38205f6e55d45118d7c Mon Sep 17 00:00:00 2001 From: Ahmon Date: Tue, 22 Apr 2025 22:36:27 -0700 Subject: [PATCH 2/3] feat: Implement Google Auth and Course Map feature Adds Google OAuth login/logout functionality and introduces a new Course Map feature using React Flow. - **Authentication:** - Integrate `@react-oauth/google` for user authentication. - Implement login success/error handling, decoding JWT with `jwt-decode`. - Persist user session state in `localStorage`. - Add logout functionality, clearing state and `localStorage`. - Update `main.jsx` to include `GoogleOAuthProvider` and use Vite environment variables (`VITE_GOOGLE_CLIENT_ID`) via `dotenv`. - Add a navigation bar in `App.jsx` showing login status, user info, and conditional links (e.g., Course Map). - **Course Map:** - Add `reactflow` dependency and its related packages. - Create new `CourseMap.jsx` component. - Implement React Flow canvas for visualizing course prerequisites. - Add functionality to add, connect, rename, and delete nodes/edges. - Implement API integration (`fetchData`) for CRUD operations on user-specific course maps (`/course-maps`, `/course-map/{id}`). - Load list of saved maps for the user. - Load specific map data (nodes, edges). - Save new or update existing maps (prompts for name if new/untitled). - Delete selected map with confirmation. - Integrate user authentication (sending ID token) for API requests. - Add `localStorage` caching for map list and individual map data to improve performance. - Add map management UI (dropdown selector, New, Save, Delete buttons). - **Chat Interface:** - Refactor `ChatInterface.jsx` to manage and send conversation history to the backend API. - Send `image_filenames` context only with the *first* message of a session. - Improve UI: auto-scrolling, message bubble styling, placeholder text, context clearing on agreement change. - **Performance:** - Add `localStorage` caching for institution and academic year data in `CollegeTransferForm.jsx` to reduce API calls. - **Dependencies:** - Add `@react-oauth/google`, `dotenv`, `jwt-decode`, `reactflow`. --- backend/college_transfer_ai/app.py | 398 +++++++++++------ package-lock.json | 568 ++++++++++++++++++++++++- package.json | 6 +- src/App.jsx | 154 ++++++- src/components/AgreementViewerPage.jsx | 2 - src/components/ChatInterface.jsx | 138 ++++-- src/components/CollegeTransferForm.jsx | 88 +++- src/components/CourseMap.jsx | 488 +++++++++++++++++++++ src/main.jsx | 28 +- 9 files changed, 1692 insertions(+), 178 deletions(-) create mode 100644 src/components/CourseMap.jsx diff --git a/backend/college_transfer_ai/app.py b/backend/college_transfer_ai/app.py index e498595..1713bb2 100644 --- a/backend/college_transfer_ai/app.py +++ b/backend/college_transfer_ai/app.py @@ -1,57 +1,91 @@ import os +import base64 +import traceback +import uuid # Import uuid for map IDs +import datetime # Import datetime from flask import Flask, jsonify, request, Response from flask_cors import CORS from backend.college_transfer_ai.college_transfer_API import CollegeTransferAPI -import json import gridfs from pymongo import MongoClient -import fitz # Import PyMuPDF -import base64 # Needed for image encoding -from openai import OpenAI # Import OpenAI library -from dotenv import load_dotenv # To load environment variables +import fitz +from openai import OpenAI +from dotenv import load_dotenv -print("--- Flask app.py loading ---") +# --- Google Auth Imports --- +from google.oauth2 import id_token +from google.auth.transport import requests as google_requests +# --- End Google Auth Imports --- -# Load environment variables from .env file +print("--- Flask app.py loading ---") load_dotenv() -# --- OpenAI Client Setup --- -# Ensure you have OPENAI_API_KEY set in your .env file or environment variables +# --- Config Vars --- openai_api_key = os.getenv("OPENAI_API_KEY") -if not openai_api_key: - print("Warning: OPENAI_API_KEY environment variable not set.") - # Optionally, raise an error or handle appropriately - # raise ValueError("OPENAI_API_KEY environment variable not set.") -openai_client = OpenAI(api_key=openai_api_key) -# --- End OpenAI Setup --- +MONGO_URI = os.getenv("MONGO_URI") +GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID") +# --- End Config Vars --- + +# --- Client Setups --- +if not openai_api_key: print("Warning: OPENAI_API_KEY not set.") +openai_client = OpenAI(api_key=openai_api_key) if openai_api_key else None + +if not MONGO_URI: print("CRITICAL: MONGO_URI not set.") +try: + client = MongoClient(MONGO_URI) + client.admin.command('ping') + print("MongoDB connection successful.") + db = client["CollegeTransferAICluster"] + fs = gridfs.GridFS(db) + course_maps_collection = db["course_maps"] # Collection remains the same name + # Ensure index on google_user_id and map_id for efficient lookups + course_maps_collection.create_index([("google_user_id", 1)]) + course_maps_collection.create_index([("google_user_id", 1), ("map_id", 1)], unique=True) + +except Exception as e: + print(f"CRITICAL: Failed to connect to MongoDB or create index: {e}") + # exit(1) + +if not GOOGLE_CLIENT_ID: + print("Warning: GOOGLE_CLIENT_ID not set. Google Sign-In endpoints will fail.") +# --- End Client Setups --- BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) -app = Flask( - __name__, - template_folder=os.path.join(BASE_DIR, 'templates'), - static_folder=os.path.join(BASE_DIR, 'static') -) -CORS(app) - -# --- Set Max Request Size --- -# Example: Limit request size to 16 megabytes +app = Flask(__name__, template_folder=os.path.join(BASE_DIR, 'templates'), static_folder=os.path.join(BASE_DIR, 'static')) +CORS(app, supports_credentials=True, origins=["http://localhost:5173"]) app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 -# --- End Max Request Size --- api = CollegeTransferAPI() -# --- MongoDB Setup --- -MONGO_URI = os.getenv("MONGO_URI") # Use env var or default -client = MongoClient(MONGO_URI) -db = client["CollegeTransferAICluster"] # Consider using a specific DB name from env var if needed -fs = gridfs.GridFS(db) -# --- End MongoDB Setup --- +# --- Helper: Verify Google Token (remains the same) --- +def verify_google_token(token): + """Verifies Google ID token and returns user info.""" + if not GOOGLE_CLIENT_ID: + raise ValueError("Google Client ID not configured on backend.") + try: + idinfo = id_token.verify_oauth2_token( + token, google_requests.Request(), GOOGLE_CLIENT_ID + ) + google_user_id = idinfo['sub'] + print(f"Token verified for user: {google_user_id}") + return idinfo + except ValueError as e: + print(f"Token verification failed: {e}") + raise ValueError(f"Invalid Google token: {e}") + except Exception as e: + print(f"An unexpected error occurred during token verification: {e}") + raise ValueError(f"Token verification error: {e}") +# --- End Helper --- + +# --- Existing Endpoints (Home, Institutions, PDF/Image handling, Chat etc. remain the same) --- @app.route('/') -def home(): - return "College Transfer AI API is running." - +def home(): return "College Transfer AI API is running." +# ... /institutions, /receiving-institutions, /academic-years, /majors ... +# ... /articulation-agreement, /pdf-images, /image ... +# ... /chat ... +# (Keep all existing endpoints as they were) # Endpoint to get all institutions @app.route('/institutions', methods=['GET']) def get_institutions(): @@ -139,33 +173,27 @@ def get_pdf_images(filename): if fs.exists({"filename": first_image_name}): for page_num in range(len(doc)): img_filename = f"{filename}_page_{page_num}.png" - # Verify each image exists, not just the first if fs.exists({"filename": img_filename}): image_filenames.append(img_filename) else: - # If one is missing, break and regenerate all (or handle differently) print(f"Cache incomplete, image {img_filename} missing. Regenerating.") - image_filenames = [] # Reset + image_filenames = [] break - if image_filenames: # If loop completed without break + if image_filenames: print(f"All images for {filename} found in cache.") doc.close() return jsonify({"image_filenames": image_filenames}) # If not fully cached, extract/save print(f"Generating images for {filename}...") - image_filenames = [] # Ensure it's empty before regenerating + image_filenames = [] for page_num in range(len(doc)): page = doc.load_page(page_num) pix = page.get_pixmap(dpi=150) img_bytes = pix.tobytes("png") img_filename = f"{filename}_page_{page_num}.png" - - # Delete existing before putting new one (optional, ensures overwrite) existing_file = fs.find_one({"filename": img_filename}) - if existing_file: - fs.delete(existing_file._id) - + if existing_file: fs.delete(existing_file._id) fs.put(img_bytes, filename=img_filename, contentType='image/png') image_filenames.append(img_filename) print(f"Saved image {img_filename}") @@ -175,6 +203,7 @@ def get_pdf_images(filename): except Exception as e: print(f"Error extracting images for {filename}: {e}") + traceback.print_exc() # Print full traceback for debugging return jsonify({"error": f"Failed to extract images: {str(e)}"}), 500 # Endpoint to serve a single image @@ -182,96 +211,225 @@ def get_pdf_images(filename): def serve_image(image_filename): try: grid_out = fs.find_one({"filename": image_filename}) - if not grid_out: - return "Image not found", 404 - image_data = grid_out.read() - # Use content type from GridFS if available, default to image/png - response_mimetype = getattr(grid_out, 'contentType', 'image/png') - response = Response(image_data, mimetype=response_mimetype) + if not grid_out: return "Image not found", 404 + response = Response(grid_out.read(), mimetype=getattr(grid_out, 'contentType', 'image/png')) return response except Exception as e: print(f"Error serving image {image_filename}: {e}") return jsonify({"error": f"Failed to serve image: {str(e)}"}), 500 -# --- NEW: Chat Endpoint --- +# Chat Endpoint (remains the same, does not use Google Auth) @app.route('/chat', methods=['POST']) def chat_with_agreement(): - if not openai_client: - return jsonify({"error": "OpenAI client not configured. Check API key."}), 500 - + if not openai_client: return jsonify({"error": "OpenAI client not configured."}), 500 try: data = request.get_json() - if not data: - return jsonify({"error": "Invalid JSON payload"}), 400 - - user_message = data.get('message') + if not data: return jsonify({"error": "Invalid JSON payload"}), 400 + new_user_message_text = data.get('new_message') + conversation_history = data.get('history', []) image_filenames = data.get('image_filenames') + if not new_user_message_text: return jsonify({"error": "Missing 'new_message' text"}), 400 + + new_openai_message_content = [{"type": "text", "text": new_user_message_text}] + if image_filenames: + print(f"Processing {len(image_filenames)} images for the first turn.") + image_count = 0 + for filename in image_filenames: + try: + grid_out = fs.find_one({"filename": filename}) + if not grid_out: continue + base64_image = base64.b64encode(grid_out.read()).decode('utf-8') + new_openai_message_content.append({ + "type": "image_url", + "image_url": {"url": f"data:{getattr(grid_out, 'contentType', 'image/png')};base64,{base64_image}"} + }) + image_count += 1 + except Exception as img_err: print(f"Error reading/encoding image {filename}: {img_err}. Skipping.") + print(f"Added {image_count} images.") + else: print("No image filenames provided.") + + conversation_history.append({"role": "user", "content": new_openai_message_content}) + + print(f"Sending request to OpenAI with {len(conversation_history)} messages...") + chat_completion = openai_client.chat.completions.create( + model="gpt-4o-mini", messages=conversation_history, max_tokens=1000 + ) + assistant_reply = chat_completion.choices[0].message.content + print(f"Received reply from OpenAI.") + return jsonify({"reply": assistant_reply}) - if not user_message or not image_filenames: - return jsonify({"error": "Missing 'message' or 'image_filenames' in request"}), 400 - - if not isinstance(image_filenames, list): - return jsonify({"error": "'image_filenames' must be a list"}), 400 - - print(f"Received chat request: '{user_message}' with {len(image_filenames)} images.") - - # Prepare message content for OpenAI API (multimodal) - openai_message_content = [{"type": "text", "text": user_message}] - image_count = 0 - for filename in image_filenames: - try: - grid_out = fs.find_one({"filename": filename}) - if not grid_out: - print(f"Warning: Image '{filename}' not found in GridFS. Skipping.") - continue # Skip this image - - image_data = grid_out.read() - base64_image = base64.b64encode(image_data).decode('utf-8') - openai_message_content.append({ - "type": "image_url", - "image_url": { - # Ensure correct mime type if not always PNG - "url": f"data:{getattr(grid_out, 'contentType', 'image/png')};base64,{base64_image}" - } - }) - image_count += 1 - except Exception as img_err: - print(f"Error reading/encoding image {filename}: {img_err}. Skipping.") - # Optionally return an error if images are critical - # return jsonify({"error": f"Failed to process image {filename}: {img_err}"}), 500 - - if image_count == 0: - return jsonify({"error": "No valid images found or processed for context."}), 400 - - # Call OpenAI API - print(f"Sending request to OpenAI with text and {image_count} images...") - try: - chat_completion = openai_client.chat.completions.create( - model="gpt-4o-mini", # Use the appropriate vision model - messages=[ - { - "role": "user", - "content": openai_message_content, - } - ], - max_tokens=1000 # Adjust as needed - ) + except Exception as e: + print(f"Error in /chat endpoint: {e}") + traceback.print_exc() + return jsonify({"error": f"An unexpected error occurred: {str(e)}"}), 500 +# --- End Existing Endpoints --- - # Extract the reply - reply = chat_completion.choices[0].message.content - print(f"Received reply from OpenAI: '{reply[:100]}...'") # Log snippet - return jsonify({"reply": reply}) - except Exception as openai_err: - print(f"OpenAI API error: {openai_err}") - return jsonify({"error": f"OpenAI API error: {str(openai_err)}"}), 500 +# --- UPDATED/NEW: Course Map Endpoints --- +# GET /api/course-maps - List all maps for the user +@app.route('/course-maps', methods=['GET']) +def list_course_maps(): + try: + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return jsonify({"error": "Authorization token missing or invalid"}), 401 + token = auth_header.split(' ')[1] + user_info = verify_google_token(token) + google_user_id = user_info['sub'] + + # Find maps for the user, projecting only necessary fields + maps_cursor = course_maps_collection.find( + {'google_user_id': google_user_id}, + {'_id': 0, 'map_id': 1, 'map_name': 1, 'last_updated': 1} # Project fields + ).sort('last_updated', -1) # Sort by most recently updated + + map_list = list(maps_cursor) + print(f"Found {len(map_list)} maps for user {google_user_id}") + return jsonify(map_list), 200 + + except ValueError as auth_err: + return jsonify({"error": str(auth_err)}), 401 except Exception as e: - print(f"Error in /chat endpoint: {e}") - return jsonify({"error": f"An unexpected error occurred: {str(e)}"}), 500 -# --- End Chat Endpoint --- + print(f"Error listing course maps: {e}") + traceback.print_exc() + return jsonify({"error": f"Failed to list course maps: {str(e)}"}), 500 + +# GET /api/course-map/ - Load a specific map +@app.route('/course-map/', methods=['GET']) +def load_specific_course_map(map_id): + try: + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return jsonify({"error": "Authorization token missing or invalid"}), 401 + token = auth_header.split(' ')[1] + user_info = verify_google_token(token) + google_user_id = user_info['sub'] + + # Find the specific map for the user + map_data = course_maps_collection.find_one( + {'google_user_id': google_user_id, 'map_id': map_id}, + {'_id': 0} # Exclude MongoDB ID + ) + + if map_data: + print(f"Loaded map {map_id} for user {google_user_id}") + return jsonify(map_data), 200 + else: + print(f"Map {map_id} not found for user {google_user_id}") + return jsonify({"error": "Map not found"}), 404 + + except ValueError as auth_err: + return jsonify({"error": str(auth_err)}), 401 + except Exception as e: + print(f"Error loading course map {map_id}: {e}") + traceback.print_exc() + return jsonify({"error": f"Failed to load course map: {str(e)}"}), 500 + +# POST /api/course-map - Save/Update a map +@app.route('/course-map', methods=['POST']) +def save_or_update_course_map(): + try: + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return jsonify({"error": "Authorization token missing or invalid"}), 401 + token = auth_header.split(' ')[1] + user_info = verify_google_token(token) + google_user_id = user_info['sub'] + + data = request.get_json() + if not data or 'nodes' not in data or 'edges' not in data: + return jsonify({"error": "Invalid payload: 'nodes' and 'edges' required"}), 400 + + nodes = data['nodes'] + edges = data['edges'] + map_id = data.get('map_id') # Get map_id if provided (for updates) + map_name = data.get('map_name', 'Untitled Map') # Get name or use default + + current_time = datetime.datetime.utcnow() + + if map_id: # Update existing map + print(f"Updating map {map_id} for user {google_user_id}") + result = course_maps_collection.update_one( + {'google_user_id': google_user_id, 'map_id': map_id}, + {'$set': { + 'map_name': map_name, + 'nodes': nodes, + 'edges': edges, + 'last_updated': current_time + }} + ) + if result.matched_count == 0: + return jsonify({"error": "Map not found or permission denied"}), 404 + saved_map_id = map_id + message = "Course map updated successfully" + else: # Create new map + new_map_id = str(uuid.uuid4()) # Generate a new unique ID + print(f"Creating new map {new_map_id} for user {google_user_id}") + map_document = { + 'google_user_id': google_user_id, + 'map_id': new_map_id, + 'map_name': map_name, + 'nodes': nodes, + 'edges': edges, + 'created_at': current_time, # Add created timestamp + 'last_updated': current_time + } + result = course_maps_collection.insert_one(map_document) + saved_map_id = new_map_id + message = "Course map created successfully" + + return jsonify({"message": message, "map_id": saved_map_id}), 200 # Return the map_id + + except ValueError as auth_err: + return jsonify({"error": str(auth_err)}), 401 + except Exception as e: + print(f"Error saving/updating course map: {e}") + traceback.print_exc() + return jsonify({"error": f"Failed to save course map: {str(e)}"}), 500 + +# DELETE /api/course-map/ - Delete a specific map +@app.route('/course-map/', methods=['DELETE']) +def delete_specific_course_map(map_id): + try: + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return jsonify({"error": "Authorization token missing or invalid"}), 401 + token = auth_header.split(' ')[1] + user_info = verify_google_token(token) + google_user_id = user_info['sub'] + + # --- Add Logging --- + print(f"[Delete Request] Received Map ID: {map_id}, User ID from Token: {google_user_id}") + # --- End Logging --- + + # Delete the specific map for the user + result = course_maps_collection.delete_one( + {'google_user_id': google_user_id, 'map_id': map_id} + ) + + # --- Add Logging --- + print(f"[Delete Result] Matched: {result.matched_count}, Deleted: {result.deleted_count}") + # --- End Logging --- + + if result.deleted_count > 0: + print(f"Deleted map {map_id} for user {google_user_id}") + # ... remove cache key if needed ... + return jsonify({"message": "Map deleted successfully"}), 200 + else: + print(f"Map {map_id} not found for deletion for user {google_user_id}") + return jsonify({"error": "Map not found or permission denied"}), 404 + + except ValueError as auth_err: + return jsonify({"error": str(auth_err)}), 401 + except Exception as e: + print(f"Error deleting course map {map_id}: {e}") + traceback.print_exc() + return jsonify({"error": f"Failed to delete course map: {str(e)}"}), 500 +# --- End Course Map Endpoints --- + if __name__ == '__main__': - # Use host='0.0.0.0' to be accessible on the network if needed - # Use debug=False in production - app.run(host='0.0.0.0', port=5000, debug=True) \ No newline at end of file + is_debug = os.getenv("FLASK_DEBUG", "False").lower() in ("true", "1", "t") + print(f"Running Flask app with debug={is_debug}") + app.run(host='0.0.0.0', port=5000, debug=is_debug) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c6e812b..632ad80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,13 @@ "name": "collegetransferai", "version": "0.0.0", "dependencies": { + "@react-oauth/google": "^0.12.1", + "dotenv": "^16.5.0", + "jwt-decode": "^4.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^6.30.0" + "react-router-dom": "^6.30.0", + "reactflow": "^11.11.4" }, "devDependencies": { "@eslint/js": "^9.22.0", @@ -666,6 +670,118 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@react-oauth/google": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.12.1.tgz", + "integrity": "sha512-qagsy22t+7UdkYAiT5ZhfM4StXi9PPNvw0zuwNmabrWyMKddczMtBIOARflbaIj+wHiQjnMAsZmzsUYuXeyoSg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@reactflow/background": { + "version": "11.3.14", + "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz", + "integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/controls": { + "version": "11.2.14", + "resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz", + "integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/core": { + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz", + "integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==", + "license": "MIT", + "dependencies": { + "@types/d3": "^7.4.0", + "@types/d3-drag": "^3.0.1", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/minimap": { + "version": "11.7.14", + "resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz", + "integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-resizer": { + "version": "2.2.14", + "resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz", + "integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.4", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-toolbar": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz", + "integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, "node_modules/@remix-run/router": { "version": "1.23.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", @@ -1181,6 +1297,259 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz", + "integrity": "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", @@ -1188,6 +1557,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1199,7 +1574,7 @@ "version": "19.1.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.2.tgz", "integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -1336,6 +1711,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1382,9 +1763,114 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -1410,6 +1896,18 @@ "dev": true, "license": "MIT" }, + "node_modules/dotenv": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/esbuild": { "version": "0.25.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz", @@ -1881,6 +2379,15 @@ "dev": true, "license": "MIT" }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2179,6 +2686,24 @@ "react-dom": ">=16.8" } }, + "node_modules/reactflow": { + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz", + "integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==", + "license": "MIT", + "dependencies": { + "@reactflow/background": "11.3.14", + "@reactflow/controls": "11.2.14", + "@reactflow/core": "11.11.4", + "@reactflow/minimap": "11.7.14", + "@reactflow/node-resizer": "2.2.14", + "@reactflow/node-toolbar": "1.3.14" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2334,6 +2859,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "6.3.2", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.2.tgz", @@ -2447,6 +2981,34 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.6.tgz", + "integrity": "sha512-ibr/n1hBzLLj5Y+yUcU7dYw8p6WnIVzdJbnX+1YpaScvZVF2ziugqHs+LAmHw4lWO9c/zRj+K1ncgWDQuthEdQ==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 26c88ae..71d8da3 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,13 @@ "preview": "vite preview" }, "dependencies": { + "@react-oauth/google": "^0.12.1", + "dotenv": "^16.5.0", + "jwt-decode": "^4.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^6.30.0" + "react-router-dom": "^6.30.0", + "reactflow": "^11.11.4" }, "devDependencies": { "@eslint/js": "^9.22.0", diff --git a/src/App.jsx b/src/App.jsx index 5c2f28e..d2e348f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,21 +1,149 @@ -import React from 'react'; -import { Routes, Route } from 'react-router-dom'; +import React, { useState, useEffect } from 'react'; // Import useEffect +import { Routes, Route, Link, useNavigate } from 'react-router-dom'; +import { GoogleLogin, googleLogout } from '@react-oauth/google'; +import { jwtDecode } from "jwt-decode"; import CollegeTransferForm from './components/CollegeTransferForm'; -import AgreementViewerPage from './components/AgreementViewerPage'; // Import the new combined page +import AgreementViewerPage from './components/AgreementViewerPage'; +import CourseMap from './components/CourseMap'; +import './App.css'; + +// Define a key for localStorage +const USER_STORAGE_KEY = 'collegeTransferUser'; function App() { - return ( - - {/* Route for the main form */} - } /> + // Initialize user state from localStorage on initial load + const [user, setUser] = useState(() => { + try { + const storedUser = localStorage.getItem(USER_STORAGE_KEY); + if (storedUser) { + const parsedUser = JSON.parse(storedUser); + // Optional: Add token expiration check here + // const decoded = jwtDecode(parsedUser.idToken); + // if (decoded.exp * 1000 < Date.now()) { + // console.log("Stored token expired, clearing storage."); + // localStorage.removeItem(USER_STORAGE_KEY); + // return null; // Treat as logged out + // } + console.log("Loaded user from localStorage"); + return parsedUser; + } + } catch (error) { + console.error("Failed to load user from localStorage:", error); + localStorage.removeItem(USER_STORAGE_KEY); // Clear corrupted data + } + return null; // Default to null if nothing valid is stored + }); + + const navigate = useNavigate(); + + // Function to handle successful login + const handleLoginSuccess = (credentialResponse) => { + console.log("Google Login Success:", credentialResponse); + try { + const decoded = jwtDecode(credentialResponse.credential); + console.log("Decoded JWT:", decoded); + const newUser = { + idToken: credentialResponse.credential, + id: decoded.sub, + name: decoded.name, + email: decoded.email, + }; + setUser(newUser); // Update React state + + // Save user data to localStorage + try { + localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(newUser)); + console.log("Saved user to localStorage"); + } catch (storageError) { + console.error("Failed to save user to localStorage:", storageError); + } + + } catch (error) { + console.error("Error decoding JWT:", error); + setUser(null); + localStorage.removeItem(USER_STORAGE_KEY); // Clear storage on error + } + }; - {/* Route for the combined Agreement Viewer */} - } - /> + const handleLoginError = () => { + console.error("Google Login Failed"); + setUser(null); + localStorage.removeItem(USER_STORAGE_KEY); // Clear storage on login error + }; + + // Function to handle logout + const handleLogout = () => { + googleLogout(); // Clear Google session + setUser(null); // Clear React state + + // Remove user data from localStorage + try { + localStorage.removeItem(USER_STORAGE_KEY); + console.log("Removed user from localStorage"); + } catch (storageError) { + console.error("Failed to remove user from localStorage:", storageError); + } + + console.log("User logged out"); + navigate('/'); + }; + + // Optional: Effect to listen for storage changes in other tabs (advanced) + // useEffect(() => { + // const handleStorageChange = (event) => { + // if (event.key === USER_STORAGE_KEY) { + // if (!event.newValue) { // User logged out in another tab + // setUser(null); + // } else { // User logged in/updated in another tab + // try { + // setUser(JSON.parse(event.newValue)); + // } catch { + // setUser(null); + // } + // } + // } + // }; + // window.addEventListener('storage', handleStorageChange); + // return () => window.removeEventListener('storage', handleStorageChange); + // }, []); + + return ( + <> + {/* Navigation/Header remains the same */} + - + {/* Routes remain the same */} + + } /> + } + /> + :

Please log in to view the course map.

} + /> +
+ ); } diff --git a/src/components/AgreementViewerPage.jsx b/src/components/AgreementViewerPage.jsx index 4fa87a7..77bb0fa 100644 --- a/src/components/AgreementViewerPage.jsx +++ b/src/components/AgreementViewerPage.jsx @@ -64,8 +64,6 @@ function AgreementViewerPage() { setMajors(cachedMajors); setIsLoadingMajors(false); setError(null); - // Optional: You could still fetch in the background here to update the cache - // if you want fresher data without blocking the initial load. return; // Exit early if loaded from cache } } catch (e) { diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx index 170634c..976faa3 100644 --- a/src/components/ChatInterface.jsx +++ b/src/components/ChatInterface.jsx @@ -1,55 +1,98 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; // Make sure useRef is imported if needed elsewhere import { fetchData } from '../services/api'; function ChatInterface({ imageFilenames, selectedMajorName }) { - const [messages, setMessages] = useState([]); const [userInput, setUserInput] = useState(''); + const [messages, setMessages] = useState([]); // State to hold the conversation history const [isLoading, setIsLoading] = useState(false); const [chatError, setChatError] = useState(null); + const [messageNum, setMessageNum] = useState(0); // Track the number of messages sent/received pairs - // Clear chat when imageFilenames change (new agreement selected) + // Ref for scrolling + const messagesEndRef = useRef(null); + + // Scroll to bottom effect + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); // Trigger scroll whenever messages update + + // Clear chat and reset message count when agreement context changes useEffect(() => { + setMessages([]); // Clear history setUserInput(''); setChatError(null); - }, [imageFilenames, selectedMajorName]); + setMessageNum(0); // Reset message counter for new agreement + console.log("Chat cleared due to new agreement context."); + }, [imageFilenames, selectedMajorName]); // Depend on the context identifiers const handleSend = async () => { - if (!userInput.trim() || isLoading || !imageFilenames || imageFilenames.length === 0) return; + // Basic guard: Check for input, loading state. + // Allow sending even if imageFilenames is empty *after* the first message. + if (!userInput.trim() || isLoading) return; + // Guard specifically for the *first* message if images are required then. + if (messageNum < 1 && (!imageFilenames || imageFilenames.length === 0)) { + console.warn("Attempted to send first message without image filenames."); + setChatError("Agreement images not loaded yet. Cannot start chat."); // Inform user + return; + } + - const userMessage = { type: 'user', text: userInput }; - setMessages(prev => [...prev, userMessage]); const currentInput = userInput; // Capture input before clearing - setUserInput(''); + const currentHistory = [...messages]; // Capture history *before* adding the new user message + + // Add user message to local state immediately for UI responsiveness + setMessages(prev => [...prev, { type: 'user', text: currentInput }]); + setUserInput(''); // Clear input field setIsLoading(true); setChatError(null); + // --- Prepare data for Backend --- + // Map frontend message state to the format expected by the backend/OpenAI + const apiHistory = currentHistory.map(msg => ({ + role: msg.type === 'bot' ? 'assistant' : msg.type, // Map 'bot' to 'assistant' + content: msg.text // Assuming simple text content for history + // NOTE: This simple mapping assumes previous messages didn't contain complex content like images. + // If the assistant could previously return images, or if user could upload images mid-convo, + // the 'content' structure here and in the backend would need to be more robust. + })); + + const payload = { + new_message: currentInput, + history: apiHistory + }; + + // Add image_filenames only for the very first message (messageNum is 0) + const shouldSendImages = messageNum < 1; + if (shouldSendImages) { + payload.image_filenames = imageFilenames; + console.log("Sending image filenames with the first message."); + } + // --- Backend Call --- try { - // *** Pass only 'chat' as the endpoint *** + console.log("Sending to /chat:", payload); // Log what's being sent const response = await fetchData('chat', { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ - message: currentInput, - image_filenames: imageFilenames - }) + body: JSON.stringify(payload) // Send new message, history, and optional images }); // Check if the response contains a reply - if (response && response.reply) { // Check if response is not null + if (response && response.reply) { + // Add bot reply to local state setMessages(prev => [...prev, { type: 'bot', text: response.reply }]); + setMessageNum(prev => prev + 1); // Increment message counter *after* successful round trip } else { - // If no reply, throw an error using the error message from the backend if available - // Check response object itself before accessing .error + // Handle cases where backend might return an error structure differently throw new Error(response?.error || "No reply received or unexpected response format from chat API."); } } catch (err) { console.error("Chat API error:", err); - // Display the error message to the user setChatError(`Failed to get response: ${err.message}`); - // Optionally add a system message indicating the error + // Optionally add a system message indicating the error, or revert the user message + // Reverting might be complex, adding a system error is simpler: setMessages(prev => [...prev, { type: 'system', text: `Error: ${err.message}` }]); } finally { setIsLoading(false); @@ -57,37 +100,68 @@ function ChatInterface({ imageFilenames, selectedMajorName }) { // --- End Backend Call --- }; + // Disable input/button logic adjusted: + // Disable if loading. + // Disable if it's the first message (messageNum === 0) AND imageFilenames are missing/empty. + const isSendDisabled = isLoading || !userInput.trim() || (messageNum < 1 && (!imageFilenames || imageFilenames.length === 0)); + const placeholderText = (messageNum < 1 && (!imageFilenames || imageFilenames.length === 0)) + ? "Loading agreement context..." + : "Ask about the agreement..."; + + return ( -
+
+ {/* Optional Header */} +
+ Chatting about: {selectedMajorName || "Selected Agreement"} +
+ + {/* Message Display Area */}
+ {messages.length === 0 && !isLoading && ( +
+ {placeholderText === "Loading agreement context..." ? placeholderText : "Ask a question about the selected transfer agreement."} +
+ )} {messages.map((msg, index) => ( -
+
{msg.text}
))} - {isLoading &&

Thinking...

} - {chatError &&

{chatError}

} + {/* Add a ref to the end of the messages list for scrolling */} +
+ {/* Loading/Error Indicators */} + {isLoading &&

Thinking...

} + {chatError && !isLoading &&

{chatError}

}
-
+ + {/* Input Area */} +
setUserInput(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleSend()} - placeholder="Select a major..." - style={{ flexGrow: 1, marginRight: '10px', padding: '8px' }} - disabled={isLoading || !imageFilenames || imageFilenames.length === 0} + onKeyDown={(e) => e.key === 'Enter' && !isSendDisabled && handleSend()} + placeholder={placeholderText} + style={{ flexGrow: 1, marginRight: '10px', padding: '10px', borderRadius: '20px', border: '1px solid #ccc' }} + disabled={isLoading || (messageNum < 1 && (!imageFilenames || imageFilenames.length === 0))} // Simplified disable logic /> -
diff --git a/src/components/CollegeTransferForm.jsx b/src/components/CollegeTransferForm.jsx index 983f519..68825a6 100644 --- a/src/components/CollegeTransferForm.jsx +++ b/src/components/CollegeTransferForm.jsx @@ -53,11 +53,67 @@ const CollegeTransferForm = () => { // --- Effects for Initial Data Loading --- useEffect(() => { + const cacheInstitutionsKey = "instutions" + let cachedInstitutions = null + + try { + const cachedData = localStorage.getItem(cacheInstitutionsKey); + if (cachedData) { + cachedInstitutions = JSON.parse(cachedData); + console.log("Loaded institutions from cache:", cacheInstitutionsKey); + setInstitutions(cachedInstitutions); + setError(null); + return; + } + } catch (e) { + console.error("Error loading institutions from cache:", e); + localStorage.removeItem(cacheInstitutionsKey); // Clear cache on error + } + + fetchData('institutions') - .then(data => setInstitutions(data)) + .then(data => { + setInstitutions(data) + try { + localStorage.setItem(cacheInstitutionsKey, JSON.stringify(data)); + console.log("Institutions cached successfully:", cacheInstitutionsKey); + } catch (e) { + console.error("Error caching institutions:", e); + } + }) .catch(err => setError(`Failed to load institutions: ${err.message}`)); + + }, []); + + useEffect(() => { + + const cacheAcademicYearsKey = "academic-years" + let cachedAcademicYears = null + + try { + const cachedData = localStorage.getItem(cacheAcademicYearsKey); + if (cachedData) { + cachedAcademicYears = JSON.parse(cachedData); + console.log("Loaded academic years from cache:", cacheAcademicYearsKey); + setAcademicYears(cachedAcademicYears); + setError(null); + return; + } + } catch (e) { + console.error("Error loading academic years from cache:", e); + localStorage.removeItem(cacheAcademicYearsKey); // Clear cache on error + } + fetchData('academic-years') - .then(data => setAcademicYears(data)) + .then(data => { + setAcademicYears(data) + try { + localStorage.setItem(cacheAcademicYearsKey, JSON.stringify(data)); + console.log("Academic Years cached successfully:", cacheAcademicYearsKey); + } catch (e) { + console.error("Error caching academic years:", e); + } + }) .catch(err => setError(`Failed to load academic years: ${err.message}`)); }, []); @@ -68,9 +124,35 @@ const CollegeTransferForm = () => { setReceivingInstitutions({}); setFilteredReceiving([]); + const cacheReceivingInstitutionsKey = `receiving-instutions-${selectedSendingId}` + let cachedReceivingInstitutions = null + + try { + const cachedData = localStorage.getItem(cacheReceivingInstitutionsKey); + if (cachedData) { + cachedReceivingInstitutions = JSON.parse(cachedData); + console.log("Loaded receiving institutions from cache:", cacheReceivingInstitutionsKey); + setReceivingInstitutions(cachedReceivingInstitutions); + setError(null); + return; + } + } catch (e) { + console.error("Error loading receiving institutions from cache:", e); + localStorage.removeItem(cacheReceivingInstitutionsKey); // Clear cache on error + } + + if (selectedSendingId) { fetchData(`receiving-institutions?sendingInstitutionId=${selectedSendingId}`) - .then(data => setReceivingInstitutions(data)) + .then(data => { + setReceivingInstitutions(data) + try { + localStorage.setItem(cacheReceivingInstitutionsKey, JSON.stringify(data)); + console.log("Receiving institutions cached successfully:", cacheReceivingInstitutionsKey); + } catch (e) { + console.error("Error caching receiving institutions:", e); + } + }) .catch(err => setError(`Failed to load receiving institutions: ${err.message}`)); } }, [selectedSendingId]); diff --git a/src/components/CourseMap.jsx b/src/components/CourseMap.jsx new file mode 100644 index 0000000..1c3d04a --- /dev/null +++ b/src/components/CourseMap.jsx @@ -0,0 +1,488 @@ +// filepath: c:\Users\notto\Desktop\Desktop\Projects\CollegeTransferAI\src\components\CourseMap.jsx +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import ReactFlow, { + Controls, Background, addEdge, MiniMap, BackgroundVariant, + ReactFlowProvider, useReactFlow, useNodesState, useEdgesState, +} from 'reactflow'; +import 'reactflow/dist/style.css'; +import { fetchData } from '../services/api'; + +// --- Default/Initial Data (used for NEW maps) --- +const defaultNodes = []; // Start new maps empty +const defaultEdges = []; +// --- End Default Data --- + +let idCounter = 0; // Reset counter, will be updated based on loaded nodes +const getUniqueNodeId = () => `new_node_${idCounter++}`; + +// --- Edit Input Component (remains the same) --- +function EditInput({ element, value, onChange, onSave }) { + const inputRef = useRef(null); + useEffect(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, []); + const handleKeyDown = (event) => { + if (event.key === 'Enter' || event.key === 'Escape') onSave(); + }; + return ( +
+ + +
+ ); +} +// --- End Edit Input Component --- + +// --- Helper Functions for Cache --- +const getCacheKey = (base, userId, mapId = null) => { + if (!userId) return null; // Cannot generate key without user ID + return mapId ? `${base}-${userId}-${mapId}` : `${base}-${userId}`; +}; + +const loadFromCache = (key) => { + if (!key) return null; + try { + const cachedData = localStorage.getItem(key); + if (cachedData) { + console.log(`Loaded from cache: ${key}`); + return JSON.parse(cachedData); + } + } catch (e) { + console.error(`Failed to read or parse cache for ${key}:`, e); + localStorage.removeItem(key); // Clear potentially corrupted cache + } + return null; +}; + +const saveToCache = (key, data) => { + if (!key) return; + try { + localStorage.setItem(key, JSON.stringify(data)); + console.log(`Saved to cache: ${key}`); + } catch (e) { + console.error(`Failed to save to cache for ${key}:`, e); + // Handle potential storage limits if necessary + } +}; + +const removeFromCache = (key) => { + if (!key) return; + try { + localStorage.removeItem(key); + console.log(`Removed from cache: ${key}`); + } catch (e) { + console.error(`Failed to remove cache for ${key}:`, e); + } +}; +// --- End Helper Functions for Cache --- + + +function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. + const reactFlowWrapper = useRef(null); + const { screenToFlowPosition } = useReactFlow(); + const [nodes, setNodes, onNodesChange] = useNodesState(defaultNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(defaultEdges); + const [editingElement, setEditingElement] = useState(null); + const [editValue, setEditValue] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [saveStatus, setSaveStatus] = useState(''); + + // --- State for Multiple Maps --- + const [mapList, setMapList] = useState([]); // List of { map_id, map_name, last_updated } + const [currentMapId, setCurrentMapId] = useState(null); // ID of the currently loaded map + const [currentMapName, setCurrentMapName] = useState('Untitled Map'); // Name of the current map + const [isMapListLoading, setIsMapListLoading] = useState(true); + // --- End State for Multiple Maps --- + + const userId = user?.id; // Get user ID for cache keys + + // --- Load Map List (with Cache) --- + const loadMapList = useCallback(async (forceRefresh = false) => { + const cacheKey = getCacheKey('courseMapList', userId); + if (!userId) { + setMapList([]); + setIsMapListLoading(false); + return []; + } + + // Try loading from cache first unless forcing refresh + if (!forceRefresh) { + const cachedList = loadFromCache(cacheKey); + if (cachedList) { + setMapList(cachedList); + setIsMapListLoading(false); + return cachedList; + } + } + + // If not cached or forcing refresh, fetch from API + setIsMapListLoading(true); + try { + console.log("Fetching map list from API..."); + const list = await fetchData('course-maps', { + headers: { 'Authorization': `Bearer ${user.idToken}` } + }); + const validList = list || []; + setMapList(validList); + saveToCache(cacheKey, validList); // Save fetched list to cache + console.log("Map list fetched and cached:", validList); + return validList; + } catch (error) { + console.error("Failed to load map list:", error); + setSaveStatus(`Error loading map list: ${error.message}`); + setMapList([]); // Reset on error + removeFromCache(cacheKey); // Clear potentially stale cache on error + return []; + } finally { + setIsMapListLoading(false); + } + }, [userId, user?.idToken]); // Depend on userId and token + + // --- Load Specific Map (with Cache) --- + const loadSpecificMap = useCallback(async (mapId, forceRefresh = false) => { + const cacheKey = getCacheKey('courseMap', userId, mapId); + + if (!userId || !mapId) { // Handle new map case + setNodes(defaultNodes); + setEdges(defaultEdges); + setCurrentMapId(null); + setCurrentMapName('Untitled Map'); + idCounter = 0; + setIsLoading(false); + return; + } + + setIsLoading(true); + setSaveStatus(''); + + // Try loading from cache first unless forcing refresh + if (!forceRefresh) { + const cachedMap = loadFromCache(cacheKey); + if (cachedMap && cachedMap.nodes && cachedMap.edges) { + setNodes(cachedMap.nodes); + setEdges(cachedMap.edges); + setCurrentMapId(cachedMap.map_id); + setCurrentMapName(cachedMap.map_name || 'Untitled Map'); + idCounter = cachedMap.nodes.reduce((maxId, node) => { + const match = node.id.match(/^new_node_(\d+)$/); + return match ? Math.max(maxId, parseInt(match[1], 10) + 1) : maxId; + }, cachedMap.nodes.length); + setIsLoading(false); + return; // Exit early if loaded from cache + } + } + + // If not cached or forcing refresh, fetch from API + console.log(`Fetching map data for ID: ${mapId} from API...`); + try { + const data = await fetchData(`course-map/${mapId}`, { + headers: { 'Authorization': `Bearer ${user.idToken}` } + }); + if (data && data.nodes && data.edges) { + console.log("Loaded map data from API:", data); + setNodes(data.nodes); + setEdges(data.edges); + setCurrentMapId(data.map_id); + setCurrentMapName(data.map_name || 'Untitled Map'); + idCounter = data.nodes.reduce((maxId, node) => { + const match = node.id.match(/^new_node_(\d+)$/); + return match ? Math.max(maxId, parseInt(match[1], 10) + 1) : maxId; + }, data.nodes.length); + saveToCache(cacheKey, data); // Save fetched map to cache + } else { + console.warn(`Map ${mapId} not found or invalid data from API.`); + setSaveStatus(`Error: Map ${mapId} not found.`); + removeFromCache(cacheKey); // Remove potentially invalid cache entry + handleNewMap(); // Reset to a new map state + } + } catch (error) { + console.error(`Failed to load course map ${mapId}:`, error); + setSaveStatus(`Error loading map: ${error.message}`); + removeFromCache(cacheKey); // Remove potentially invalid cache entry + handleNewMap(); // Reset to a new map state on error + } finally { + setIsLoading(false); + } + }, [userId, user?.idToken, setNodes, setEdges]); // Added handleNewMap dependency + + // --- Initial Load Effect --- + useEffect(() => { + setIsLoading(true); // Set loading true initially + loadMapList().then((list) => { + // After loading the list, decide which map to load + if (list && list.length > 0) { + // Load the most recently updated map by default + loadSpecificMap(list[0].map_id); + } else { + // No saved maps, start with a new one + loadSpecificMap(null); // This will reset to default empty state + } + }); + }, [loadMapList, loadSpecificMap]); // Depend on the loading functions + + // --- Save Map Data (Create or Update) --- + const handleSave = useCallback(async () => { + if (!userId || !user?.idToken) { + setSaveStatus("Please log in to save."); + return; + } + setSaveStatus("Saving..."); + console.log(`Attempting to save map: ${currentMapId || '(new)'}`); + + // Prompt for name if it's a new map or untitled + let mapNameToSave = currentMapName; + if (!currentMapId || currentMapName === 'Untitled Map') { + const newName = prompt("Enter a name for this map:", currentMapName); + if (newName === null) { // User cancelled prompt + setSaveStatus(''); // Clear saving status + return; + } + mapNameToSave = newName.trim() || 'Untitled Map'; // Use new name or default + setCurrentMapName(mapNameToSave); // Update state immediately + } + + + try { + const payload = { + nodes, + edges, + map_name: mapNameToSave, // Send the potentially updated name + map_id: currentMapId // Send currentMapId (null if new) + }; + const result = await fetchData('course-map', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${user.idToken}` + }, + body: JSON.stringify(payload) + }); + + console.log("Map saved successfully:", result); + setSaveStatus("Map saved!"); + const savedMapId = result?.map_id || currentMapId; // Use returned ID if new, else current + + if (savedMapId) { + setCurrentMapId(savedMapId); // Update state if it was a new map + + // Update specific map cache + const mapCacheKey = getCacheKey('courseMap', userId, savedMapId); + // Include nodes and edges when caching the specific map after save + const updatedMapData = { + nodes, + edges, + map_id: savedMapId, + map_name: mapNameToSave, + // Optionally add/update a timestamp here if needed for cache data + // last_updated: new Date().toISOString() // Example + }; + saveToCache(mapCacheKey, updatedMapData); + + // Force refresh map list cache from API + loadMapList(true); // <-- Pass true here + } + + setTimeout(() => setSaveStatus(''), 2000); + } catch (error) { + console.error("Failed to save course map:", error); + setSaveStatus(`Error saving map: ${error.message}`); + } + }, [userId, user?.idToken, nodes, edges, currentMapId, currentMapName, loadMapList]); // Include currentMapName + + // --- Handle New Map --- + const handleNewMap = useCallback(() => { + // Optionally ask user to save current changes first + // if (/* changes detected */) { if (!confirm("Discard current changes?")) return; } + + // Prompt for the new map's name + const newName = prompt("Enter a name for the new map:", "Untitled Map"); + + if (newName === null) { + // User cancelled the prompt, do nothing + console.log("New map creation cancelled."); + return; + } + + const mapNameToSet = newName.trim() || 'Untitled Map'; // Use provided name or default + + console.log(`Creating new map state with name: ${mapNameToSet}`); + setNodes(defaultNodes); + setEdges(defaultEdges); + setCurrentMapId(null); // Clear current map ID + setCurrentMapName(mapNameToSet); // Set the name from the prompt + idCounter = 0; // Reset node counter + setSaveStatus(''); // Clear any previous save status + setIsLoading(false); // Ensure loading is false + + // Optional: Select the "[New Map]" option in the dropdown visually + const selectElement = document.getElementById('map-select'); + if (selectElement) { + selectElement.value = "__NEW__"; + } + + }, [setNodes, setEdges]); // Dependencies remain the same + + // --- Handle Map Selection Change --- + const handleMapSelectChange = (event) => { + const selectedId = event.target.value; + if (selectedId === "__NEW__") { + // Call handleNewMap which now includes the prompt + handleNewMap(); + } else { + // Load existing map (check cache first) + loadSpecificMap(selectedId); + } + }; + + // --- Handle Delete Map --- + const handleDeleteMap = useCallback(async () => { + if (!currentMapId || !userId || !user?.idToken) { + setSaveStatus("No map selected to delete or not logged in."); + return; + } + + if (!confirm(`Are you sure you want to delete the map "${currentMapName}"? This cannot be undone.`)) { + return; + } + + const mapToDeleteId = currentMapId; // Capture ID before state changes + const mapCacheKey = getCacheKey('courseMap', userId, mapToDeleteId); + + // --- Add Logging --- + console.log(`[Delete Attempt] User ID: ${userId}, Map ID: ${mapToDeleteId}`); + // --- End Logging --- + + setSaveStatus("Deleting..."); + console.log(`Attempting to delete map: ${currentMapId}`); + + try { + await fetchData(`course-map/${mapToDeleteId}`, { // Ensure mapToDeleteId is correct here + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${user.idToken}` + } + }); + + console.log("Map deleted successfully."); + setSaveStatus("Map deleted."); + removeFromCache(mapCacheKey); // <-- Used here + // Load the list again and load the next available map (or new) + loadMapList().then((list) => { + if (list && list.length > 0) { + loadSpecificMap(list[0].map_id); // Load most recent + } else { + handleNewMap(); // No maps left, create new + } + }); + setTimeout(() => setSaveStatus(''), 2000); + + } catch (error) { + console.error(`[Delete Failed] User ID: ${userId}, Map ID: ${mapToDeleteId}`, error); + setSaveStatus(`Error deleting map: ${error.message}`); + } + }, [userId, user?.idToken, currentMapId, currentMapName, loadMapList, loadSpecificMap, handleNewMap]); + + + // --- Other Callbacks (onConnect, addNode, startEditing, handleEditChange, saveEdit, onPaneClick) remain the same --- + const onConnect = useCallback((connection) => { + const newEdge = { ...connection, label: 'Prereq' }; + setEdges((eds) => addEdge(newEdge, eds)); + }, [setEdges]); + + const addNode = useCallback(() => { + const newNodeId = getUniqueNodeId(); + const position = screenToFlowPosition({ + x: reactFlowWrapper.current.clientWidth / 2, + y: reactFlowWrapper.current.clientHeight / 3, + }); + const newNode = { id: newNodeId, position, data: { label: `New Course ${idCounter}` } }; + setNodes((nds) => nds.concat(newNode)); + }, [screenToFlowPosition, setNodes]); + + const startEditing = (element, isEdge = false) => { + setEditingElement({ ...element, type: isEdge ? 'edge' : 'node' }); + setEditValue(isEdge ? element.label || '' : element.data.label); + }; + + const onNodeDoubleClick = useCallback((event, node) => startEditing(node, false), []); + const onEdgeDoubleClick = useCallback((event, edge) => startEditing(edge, true), []); + const handleEditChange = (event) => setEditValue(event.target.value); + + const saveEdit = useCallback(() => { + if (!editingElement) return; + const newLabel = editValue.trim(); + if (editingElement.type === 'node') { + setNodes((nds) => nds.map((n) => (n.id === editingElement.id ? { ...n, data: { ...n.data, label: newLabel } } : n))); + } else if (editingElement.type === 'edge') { + setEdges((eds) => eds.map((e) => (e.id === editingElement.id ? { ...e, label: newLabel } : e))); + } + setEditingElement(null); + setEditValue(''); + }, [editingElement, editValue, setNodes, setEdges]); + + const onPaneClick = useCallback(() => saveEdit(), [saveEdit]); + // --- End Other Callbacks --- + + // Render loading state for the whole map area + if (isMapListLoading || isLoading) { + return

Loading course maps...

; + } + + return ( +
+ {editingElement && } + + {/* --- Map Management Bar --- */} +
+ + + + + + {saveStatus && {saveStatus}} +
+ {/* --- End Map Management Bar --- */} + + {/* --- Instructions Bar --- */} +
+ + | Double-click to rename | Select + Backspace/Delete to remove | Drag handles to connect +
+ {/* --- End Instructions Bar --- */} + + + + + + + {/* Note about custom nodes remains the same */} +
+ Note: To add multiple input/output handles, create a Custom Node component. +
+
+ ); +} + +// Wrap with Provider +export default function CourseMap({ user }) { + return ( + + + + ); +} \ No newline at end of file diff --git a/src/main.jsx b/src/main.jsx index c06e259..c6ad594 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,9 +1,29 @@ +import React from 'react'; import { createRoot } from 'react-dom/client'; -import { BrowserRouter } from 'react-router-dom'; // Import BrowserRouter +import { BrowserRouter } from 'react-router-dom'; +import { GoogleOAuthProvider } from '@react-oauth/google'; import App from './App.jsx'; +// Remove dotenv imports - Vite handles .env files for the frontend + +// Access the variable using import.meta.env +// Vite replaces this with the actual value during build/dev +const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID; + +// Add a check to ensure the variable is loaded +if (!googleClientId) { + console.error("FATAL ERROR: VITE_GOOGLE_CLIENT_ID is not defined."); + console.error("Ensure you have a .env file in the project root (where package.json is)"); + console.error("and the variable is named VITE_GOOGLE_CLIENT_ID=YOUR_ID"); + // You might want to render an error message to the user here instead of proceeding +} createRoot(document.getElementById('root')).render( - {/* Wrap App with BrowserRouter */} - - + + {/* Pass the loaded client ID */} + + + + + + ); From dc9e151c680cd0bd6effe0f758b2dcebe825a0c7 Mon Sep 17 00:00:00 2001 From: Ahmon Date: Wed, 23 Apr 2025 21:31:17 -0700 Subject: [PATCH 3/3] feat: Implement Course Map CRUD with API and Caching Refactors the Course Map component (`CourseMap.jsx`) to handle create, load, save, and delete operations via API endpoints (`/api/course-map`, `/api/course-maps`). - Implements API calls for creating, updating, loading, and deleting maps associated with the logged-in user. - Adds local storage caching for the map list and individual map data to improve performance and reduce API calls. - Refines the UI for selecting, creating, saving, and deleting maps. - Updates `handleNewMap` and `handleSave` logic for better state management and API interaction. Also includes: - Adds frontend check for Google ID token expiration on load (`App.jsx`). - Configures Vite (`vite.config.js`) to load `.env` variables, expose `VITE_GOOGLE_CLIENT_ID`, and set development server headers for OAuth popups. - Updates React Router configuration (`main.jsx`). - Minor cleanup in backend logging (`app.py`). --- backend/college_transfer_ai/app.py | 4 +- src/App.jsx | 36 ++-- src/components/CourseMap.jsx | 267 +++++++++++++++++++---------- src/main.jsx | 3 +- vite.config.js | 42 ++--- 5 files changed, 229 insertions(+), 123 deletions(-) diff --git a/backend/college_transfer_ai/app.py b/backend/college_transfer_ai/app.py index 1713bb2..c01c800 100644 --- a/backend/college_transfer_ai/app.py +++ b/backend/college_transfer_ai/app.py @@ -265,7 +265,7 @@ def chat_with_agreement(): # --- End Existing Endpoints --- -# --- UPDATED/NEW: Course Map Endpoints --- +# --- Course Map Endpoints --- # GET /api/course-maps - List all maps for the user @app.route('/course-maps', methods=['GET']) @@ -409,7 +409,7 @@ def delete_specific_course_map(map_id): ) # --- Add Logging --- - print(f"[Delete Result] Matched: {result.matched_count}, Deleted: {result.deleted_count}") + print(f"[Delete Result] Deleted: {result.deleted_count}") # --- End Logging --- if result.deleted_count > 0: diff --git a/src/App.jsx b/src/App.jsx index d2e348f..7ca7d8d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; // Import useEffect +import React, { useState } from 'react'; import { Routes, Route, Link, useNavigate } from 'react-router-dom'; import { GoogleLogin, googleLogout } from '@react-oauth/google'; import { jwtDecode } from "jwt-decode"; @@ -17,21 +17,33 @@ function App() { const storedUser = localStorage.getItem(USER_STORAGE_KEY); if (storedUser) { const parsedUser = JSON.parse(storedUser); - // Optional: Add token expiration check here - // const decoded = jwtDecode(parsedUser.idToken); - // if (decoded.exp * 1000 < Date.now()) { - // console.log("Stored token expired, clearing storage."); - // localStorage.removeItem(USER_STORAGE_KEY); - // return null; // Treat as logged out - // } - console.log("Loaded user from localStorage"); + + // *** Check Token Expiration *** + if (parsedUser.idToken) { + const decoded = jwtDecode(parsedUser.idToken); + const isExpired = decoded.exp * 1000 < Date.now(); // Convert exp (seconds) to milliseconds + + if (isExpired) { + console.log("Stored token expired, clearing storage."); + localStorage.removeItem(USER_STORAGE_KEY); + return null; // Treat as logged out + } + } else { + // Handle case where token might be missing in stored data + console.warn("Stored user data missing idToken, clearing storage."); + localStorage.removeItem(USER_STORAGE_KEY); + return null; + } + // *** End Check *** + + console.log("Loaded valid user from localStorage"); return parsedUser; } } catch (error) { - console.error("Failed to load user from localStorage:", error); - localStorage.removeItem(USER_STORAGE_KEY); // Clear corrupted data + console.error("Failed to load or validate user from localStorage:", error); + localStorage.removeItem(USER_STORAGE_KEY); // Clear corrupted/invalid data } - return null; // Default to null if nothing valid is stored + return null; // Default to null }); const navigate = useNavigate(); diff --git a/src/components/CourseMap.jsx b/src/components/CourseMap.jsx index 1c3d04a..8f4966e 100644 --- a/src/components/CourseMap.jsx +++ b/src/components/CourseMap.jsx @@ -7,6 +7,7 @@ import ReactFlow, { import 'reactflow/dist/style.css'; import { fetchData } from '../services/api'; + // --- Default/Initial Data (used for NEW maps) --- const defaultNodes = []; // Start new maps empty const defaultEdges = []; @@ -141,6 +142,83 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. } }, [userId, user?.idToken]); // Depend on userId and token + const handleNewMap = useCallback(async () => { + if (!userId || !user?.idToken) { + setSaveStatus("Please log in to create a map."); + return; + } + + const mapName = prompt("Enter a name for the new map:", "Untitled Map"); + if (mapName === null) { // User cancelled prompt + setSaveStatus(''); // Clear saving status + return; + } + console.log("Initiating new map creation via API..."); + setIsLoading(true); // Show loading state for the map area + setSaveStatus("Creating new map..."); + + try { + // Call the backend endpoint to create the empty map record + const newMapData = await fetchData('course-map', { // POST to the collection endpoint + method: 'POST', + headers: { + 'Authorization': `Bearer ${user.idToken}`, + 'Content-Type': 'application/json' // Good practice + }, + // Send the default name in the body + body: JSON.stringify({ map_name: mapName }) + }); + + if (newMapData && newMapData.map_id) { + console.log("New map record created:", newMapData); + + // 1. Update Map List State & Cache + const newMapEntry = { + map_id: newMapData.map_id, + map_name: newMapData.map_name, + last_updated: newMapData.last_updated + }; + setMapList(prevList => { + const newList = [newMapEntry, ...prevList]; + newList.sort((a, b) => new Date(b.last_updated) - new Date(a.last_updated)); + const listCacheKey = getCacheKey('courseMapList', userId); + saveToCache(listCacheKey, newList); // Update list cache + return newList; + }); + + // 2. Update Current Map State + setNodes(defaultNodes); // Reset nodes/edges for the new map + setEdges(defaultEdges); + setCurrentMapId(newMapData.map_id); // Set the new ID + setCurrentMapName(newMapData.map_name); // Set the name + idCounter = 0; // Reset node counter + + // 3. Update Specific Map Cache (optional but good practice) + const mapCacheKey = getCacheKey('courseMap', userId, newMapData.map_id); + saveToCache(mapCacheKey, { // Cache the initial empty state + nodes: defaultNodes, + edges: defaultEdges, + map_id: newMapData.map_id, + map_name: newMapData.map_name, + last_updated: newMapData.last_updated + }); + + setSaveStatus("New map created."); + setTimeout(() => setSaveStatus(''), 2000); + + } else { + throw new Error("Failed to create map record: Invalid response from server."); + } + + } catch (error) { + console.error("Failed to create new map:", error); + setSaveStatus(`Error creating map: ${error.message}`); + // Don't reset state here, keep the user's current view + } finally { + setIsLoading(false); // Hide loading state + } + }, [userId, user?.idToken, setNodes, setEdges, setMapList]); // Added setMapList dependency + // --- Load Specific Map (with Cache) --- const loadSpecificMap = useCallback(async (mapId, forceRefresh = false) => { const cacheKey = getCacheKey('courseMap', userId, mapId); @@ -206,7 +284,7 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. } finally { setIsLoading(false); } - }, [userId, user?.idToken, setNodes, setEdges]); // Added handleNewMap dependency + }, [userId, user?.idToken, setNodes, setEdges, handleNewMap]); // Added handleNewMap dependency // --- Initial Load Effect --- useEffect(() => { @@ -232,9 +310,13 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. setSaveStatus("Saving..."); console.log(`Attempting to save map: ${currentMapId || '(new)'}`); + // --- Track if it's a new map being saved --- + const isNewMapInitially = !currentMapId; + // --- End Track --- + // Prompt for name if it's a new map or untitled let mapNameToSave = currentMapName; - if (!currentMapId || currentMapName === 'Untitled Map') { + if (currentMapName === 'Untitled Map') { const newName = prompt("Enter a name for this map:", currentMapName); if (newName === null) { // User cancelled prompt setSaveStatus(''); // Clear saving status @@ -244,6 +326,15 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. setCurrentMapName(mapNameToSave); // Update state immediately } + if (!currentMapId) { + // This case should ideally not happen if handleNewMap always creates an ID first. + // But as a fallback, maybe call handleNewMap first? Or show an error. + console.error("Save attempted without a currentMapId. Please create a new map first."); + setSaveStatus("Error: Cannot save, no map selected/created."); + // OR potentially trigger handleNewMap here, though it might be confusing UX. + // await handleNewMap(); // This would create it, then the rest of save would update it immediately. + return; + } try { const payload = { @@ -263,67 +354,61 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. console.log("Map saved successfully:", result); setSaveStatus("Map saved!"); - const savedMapId = result?.map_id || currentMapId; // Use returned ID if new, else current + const savedMapId = result?.map_id; // Use returned ID if new, else current if (savedMapId) { setCurrentMapId(savedMapId); // Update state if it was a new map // Update specific map cache const mapCacheKey = getCacheKey('courseMap', userId, savedMapId); - // Include nodes and edges when caching the specific map after save const updatedMapData = { nodes, edges, map_id: savedMapId, map_name: mapNameToSave, - // Optionally add/update a timestamp here if needed for cache data - // last_updated: new Date().toISOString() // Example + last_updated: new Date().toISOString() // Add current timestamp }; saveToCache(mapCacheKey, updatedMapData); - // Force refresh map list cache from API - loadMapList(true); // <-- Pass true here + // --- Update mapList state directly if it was a new map --- + if (isNewMapInitially) { + const newMapEntry = { + map_id: savedMapId, + map_name: mapNameToSave, + last_updated: updatedMapData.last_updated // Use the same timestamp + }; + setMapList(prevList => { + // Add the new map and re-sort by date descending + const newList = [newMapEntry, ...prevList]; + newList.sort((a, b) => new Date(b.last_updated) - new Date(a.last_updated)); + // Update cache for the list as well + const listCacheKey = getCacheKey('courseMapList', userId); + saveToCache(listCacheKey, newList); + return newList; + }); + } else { + // If updating an existing map, just refresh the list from API + // to get potentially updated 'last_updated' timestamp and ensure consistency. + loadMapList(true); // Force refresh map list cache from API + } + // --- End Update --- + + } else if (!isNewMapInitially) { + // If it wasn't a new map but we didn't get an ID back (shouldn't happen on update success) + // still refresh the list just in case something changed (like the name) + loadMapList(true); } + setTimeout(() => setSaveStatus(''), 2000); } catch (error) { console.error("Failed to save course map:", error); setSaveStatus(`Error saving map: ${error.message}`); } - }, [userId, user?.idToken, nodes, edges, currentMapId, currentMapName, loadMapList]); // Include currentMapName + }, [userId, user?.idToken, nodes, edges, currentMapId, currentMapName, loadMapList, setMapList]); // Added setMapList dependency // --- Handle New Map --- - const handleNewMap = useCallback(() => { - // Optionally ask user to save current changes first - // if (/* changes detected */) { if (!confirm("Discard current changes?")) return; } - - // Prompt for the new map's name - const newName = prompt("Enter a name for the new map:", "Untitled Map"); - - if (newName === null) { - // User cancelled the prompt, do nothing - console.log("New map creation cancelled."); - return; - } - - const mapNameToSet = newName.trim() || 'Untitled Map'; // Use provided name or default - - console.log(`Creating new map state with name: ${mapNameToSet}`); - setNodes(defaultNodes); - setEdges(defaultEdges); - setCurrentMapId(null); // Clear current map ID - setCurrentMapName(mapNameToSet); // Set the name from the prompt - idCounter = 0; // Reset node counter - setSaveStatus(''); // Clear any previous save status - setIsLoading(false); // Ensure loading is false - - // Optional: Select the "[New Map]" option in the dropdown visually - const selectElement = document.getElementById('map-select'); - if (selectElement) { - selectElement.value = "__NEW__"; - } - - }, [setNodes, setEdges]); // Dependencies remain the same + // --- Handle Map Selection Change --- const handleMapSelectChange = (event) => { @@ -338,52 +423,52 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. }; // --- Handle Delete Map --- - const handleDeleteMap = useCallback(async () => { - if (!currentMapId || !userId || !user?.idToken) { - setSaveStatus("No map selected to delete or not logged in."); - return; - } - - if (!confirm(`Are you sure you want to delete the map "${currentMapName}"? This cannot be undone.`)) { - return; - } - - const mapToDeleteId = currentMapId; // Capture ID before state changes - const mapCacheKey = getCacheKey('courseMap', userId, mapToDeleteId); - - // --- Add Logging --- - console.log(`[Delete Attempt] User ID: ${userId}, Map ID: ${mapToDeleteId}`); - // --- End Logging --- - - setSaveStatus("Deleting..."); - console.log(`Attempting to delete map: ${currentMapId}`); - - try { - await fetchData(`course-map/${mapToDeleteId}`, { // Ensure mapToDeleteId is correct here - method: 'DELETE', - headers: { - 'Authorization': `Bearer ${user.idToken}` - } - }); + const handleDeleteMap = useCallback(async () => { + if (!currentMapId || !userId || !user?.idToken) { + setSaveStatus("No map selected to delete or not logged in."); + return; + } - console.log("Map deleted successfully."); - setSaveStatus("Map deleted."); - removeFromCache(mapCacheKey); // <-- Used here - // Load the list again and load the next available map (or new) - loadMapList().then((list) => { - if (list && list.length > 0) { - loadSpecificMap(list[0].map_id); // Load most recent - } else { - handleNewMap(); // No maps left, create new - } - }); - setTimeout(() => setSaveStatus(''), 2000); + if (!confirm(`Are you sure you want to delete the map "${currentMapName}"? This cannot be undone.`)) { + return; + } - } catch (error) { - console.error(`[Delete Failed] User ID: ${userId}, Map ID: ${mapToDeleteId}`, error); - setSaveStatus(`Error deleting map: ${error.message}`); - } - }, [userId, user?.idToken, currentMapId, currentMapName, loadMapList, loadSpecificMap, handleNewMap]); + const mapToDeleteId = currentMapId; // Capture ID before state changes + const mapCacheKey = getCacheKey('courseMap', userId, mapToDeleteId); + + // --- Add Logging --- + console.log(`[Delete Attempt] User ID: ${userId}, Map ID: ${mapToDeleteId}`); + // --- End Logging --- + + setSaveStatus("Deleting..."); + console.log(`Attempting to delete map: ${currentMapId}`); + + try { + await fetchData(`course-map/${mapToDeleteId}`, { // Ensure mapToDeleteId is correct here + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${user.idToken}` + } + }); + + console.log("Map deleted successfully."); + setSaveStatus("Map deleted."); + removeFromCache(mapCacheKey); // <-- Used here + // Load the list again and load the next available map (or new) + loadMapList().then((list) => { + if (list && list.length > 0) { + loadSpecificMap(list[0].map_id); // Load most recent + } else { + handleNewMap(); // No maps left, create new + } + }); + setTimeout(() => setSaveStatus(''), 2000); + + } catch (error) { + console.error(`[Delete Failed] User ID: ${userId}, Map ID: ${mapToDeleteId}`, error); + setSaveStatus(`Error deleting map: ${error.message}`); + } + }, [userId, user?.idToken, currentMapId, currentMapName, loadMapList, loadSpecificMap, handleNewMap]); // --- Other Callbacks (onConnect, addNode, startEditing, handleEditChange, saveEdit, onPaneClick) remain the same --- @@ -437,18 +522,26 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. {/* --- Map Management Bar --- */}
- + {/* Display the current map name */} + Editing: + + {currentMapName} {currentMapId ? '' : '(unsaved)'} + + | {/* Separator */} + + - + + {saveStatus && {saveStatus}}
{/* --- End Map Management Bar --- */} diff --git a/src/main.jsx b/src/main.jsx index c6ad594..ef14fef 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -19,9 +19,8 @@ if (!googleClientId) { createRoot(document.getElementById('root')).render( - {/* Pass the loaded client ID */} - + diff --git a/vite.config.js b/vite.config.js index da5afa4..605a1aa 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,5 +1,9 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react-swc' +import dotenv from 'dotenv'; + +// Load .env file +dotenv.config(); // https://vite.dev/config/ export default defineConfig({ @@ -8,26 +12,24 @@ export default defineConfig({ port: 5173, // Your frontend port proxy: { '/api': { - target: 'http://127.0.0.1:5000', // Use IPv4 address explicitly - changeOrigin: true, // Recommended - rewrite: (path) => { - console.log(`Vite proxy intercepted: ${path}`); - const rewritten = path.replace(/^\/api/, ''); - console.log(`Rewritten to: ${rewritten}`); - return rewritten; - }, - configure: (proxy) => { - proxy.on('error', (err) => { - console.log('Proxy error:', err); - }); - proxy.on('proxyReq', (proxyReq, req) => { - console.log(`Proxy request: ${req.method} ${req.url} → ${proxyReq.method} ${proxyReq.path}`); - }); - proxy.on('proxyRes', (proxyRes, req) => { - console.log(`Proxy response: ${req.method} ${req.url} → ${proxyRes.statusCode}`); - }); - } - } + target: 'http://127.0.0.1:5000', // Your Flask backend address + changeOrigin: true, + // No rewrite needed if Flask routes start with /api + }, + }, + // Adjust headers for development to allow Google OAuth popup communication + headers: { + // Option 1: Potentially allows the popup communication needed by Google + 'Cross-Origin-Opener-Policy': 'same-origin-allow-popups', + // Option 2: Less secure, use only if Option 1 doesn't work and for testing + // 'Cross-Origin-Opener-Policy': 'unsafe-none', + 'Cross-Origin-Embedder-Policy': 'require-corp', // Or 'unsafe-none' if needed } + }, + // Define environment variables for client-side access + define: { + // ESLint should no longer complain about 'process' here + // eslint-disable-next-line no-undef + 'process.env.VITE_GOOGLE_CLIENT_ID': JSON.stringify(process.env.VITE_GOOGLE_CLIENT_ID) } })