diff --git a/eduaid_web/src/App.css b/eduaid_web/src/App.css index 74b5e053..977b3483 100644 --- a/eduaid_web/src/App.css +++ b/eduaid_web/src/App.css @@ -36,3 +36,37 @@ transform: rotate(360deg); } } + +.connection-banner { + padding: 8px 12px; + text-align: center; + font-size: 0.9rem; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; +} + +.connection-banner.down { + background: #b91c1c; + color: white; +} + +.connection-banner.error { + background: #facc15; + color: #111827; +} + +.connection-banner-retry { + background: #ffffff; + color: #b91c1c; + border: none; + padding: 4px 8px; + cursor: pointer; + border-radius: 4px; + font-size: 0.85rem; +} + +.connection-banner-retry:hover { + filter: brightness(0.95); +} diff --git a/eduaid_web/src/App.js b/eduaid_web/src/App.js index e9eef0a0..f3a8fefc 100644 --- a/eduaid_web/src/App.js +++ b/eduaid_web/src/App.js @@ -1,4 +1,5 @@ import "./App.css"; +import { useEffect, useState } from "react"; import { Routes, Route, HashRouter } from "react-router-dom"; import Home from "./pages/Home"; import Question_Type from "./pages/Question_Type"; @@ -6,19 +7,60 @@ import Text_Input from "./pages/Text_Input"; import Output from "./pages/Output"; import Previous from "./pages/Previous"; import NotFound from "./pages/PageNotFound"; +import apiClient from "./utils/apiClient"; + +const initialConnectionState = { status: "unknown", detail: "" }; + +function ConnectionBanner({ connection }) { + if (connection.status === "down") { + return ( +
+ + Backend disconnected.{" "} + {connection.detail || "Please check backend server and API URL."} + + +
+ ); + } + + if (connection.status === "error") { + return ( +
+ Backend responded with an error.{" "} + {connection.detail || "Please try again shortly."} +
+ ); + } + + return null; +} function App() { + const [connection, setConnection] = useState(initialConnectionState); + + useEffect(() => apiClient.subscribeConnectionStatus(setConnection), []); + return ( - - - } /> - } /> - } /> - } /> - } /> - } /> - - + <> + + + + } /> + } /> + } /> + } /> + } /> + } /> + + + ); } diff --git a/eduaid_web/src/utils/apiClient.js b/eduaid_web/src/utils/apiClient.js index 0b9c2576..edff0599 100644 --- a/eduaid_web/src/utils/apiClient.js +++ b/eduaid_web/src/utils/apiClient.js @@ -1,89 +1,179 @@ // API client that works both in web and Electron environments class ApiClient { constructor() { - this.isElectron = typeof window !== 'undefined' && window.electronAPI; - this.baseUrl = this.isElectron - ? window.electronAPI.getApiConfig().baseUrl - : process.env.REACT_APP_BASE_URL || 'http://localhost:5000'; + this.isElectron = typeof window !== "undefined" && window.electronAPI; + this.baseUrl = this.isElectron + ? window.electronAPI.getApiConfig().baseUrl + : process.env.REACT_APP_BASE_URL || "http://localhost:5000"; + + this.listeners = new Set(); + this.currentStatus = "unknown"; // unknown | up | down | error + this.currentDetail = ""; + } + + subscribeConnectionStatus(listener) { + this.listeners.add(listener); + try { + listener({ status: this.currentStatus, detail: this.currentDetail }); + } catch (error) { + console.error("Connection listener failed:", error); + } + return () => this.listeners.delete(listener); + } + + notifyConnectionStatus(status, detail = "") { + this.listeners.forEach((listener) => { + try { + listener({ status, detail }); + } catch (error) { + console.error("Connection listener failed:", error); + } + }); + } + + setConnectionStatus(status, detail = "") { + if (this.currentStatus === status && this.currentDetail === detail) return; + this.currentStatus = status; + this.currentDetail = detail; + this.notifyConnectionStatus(status, detail); + } + + getFallbackBaseUrls() { + if (this.isElectron || process.env.REACT_APP_BASE_URL) return []; + if (typeof window === "undefined") return []; + + const host = window.location.hostname; + if (host !== "localhost" && host !== "127.0.0.1") return []; + + if (this.baseUrl.includes(":5000")) return ["http://localhost:5001"]; + if (this.baseUrl.includes(":5001")) return ["http://localhost:5000"]; + return []; + } + + async parseJsonResponse(response) { + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`HTTP ${response.status}: ${text || "Request Failed"}`); + } + + if (response.status === 204 || response.status === 205) return null; + + const contentLength = response.headers.get("Content-Length"); + if (contentLength === "0") return null; + return response.json(); + } + + async fetchJson(url, options = {}) { + const response = await fetch(url, options); + return this.parseJsonResponse(response); + } + + async requestWithFallback(endpoint, options = {}) { + const primaryUrl = `${this.baseUrl}${endpoint}`; + + try { + const data = await this.fetchJson(primaryUrl, options); + this.setConnectionStatus("up"); + return data; + } catch (error) { + const isNetworkError = error instanceof TypeError; + if (!isNetworkError) { + this.setConnectionStatus("error", error.message); + throw error; + } + + const fallbackBaseUrls = this.getFallbackBaseUrls().filter( + (url) => url !== this.baseUrl + ); + + for (const fallbackBaseUrl of fallbackBaseUrls) { + try { + const data = await this.fetchJson(`${fallbackBaseUrl}${endpoint}`, options); + this.baseUrl = fallbackBaseUrl; + this.setConnectionStatus("up"); + return data; + } catch (fallbackError) { + if (!(fallbackError instanceof TypeError)) { + this.setConnectionStatus("error", fallbackError.message); + throw fallbackError; + } + } + } + + this.setConnectionStatus( + "down", + `Cannot reach backend at ${this.baseUrl}. Check server and REACT_APP_BASE_URL.` + ); + throw error; + } } async makeRequest(endpoint, options = {}) { if (this.isElectron) { - // Use Electron's IPC for API requests try { const response = await window.electronAPI.makeApiRequest(endpoint, options); - if (response.ok) { - return response.data; - } else { - throw new Error(`API request failed with status ${response.status}`); + if (!response.ok) { + const err = new Error(`API request failed with status ${response.status}`); + err.isHttpError = true; + this.setConnectionStatus("error", err.message); + throw err; } + this.setConnectionStatus("up"); + return response.data; } catch (error) { - console.error('Electron API request failed:', error); + if (error?.isHttpError) { + throw error; + } + this.setConnectionStatus("down", error?.message || "API request failed"); throw error; } - } else { - // Use regular fetch for web environment - const url = `${this.baseUrl}${endpoint}`; - const response = await fetch(url, options); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return await response.json(); } + + return this.requestWithFallback(endpoint, options); } // Convenience methods for common HTTP verbs async get(endpoint, options = {}) { - return this.makeRequest(endpoint, { ...options, method: 'GET' }); + return this.makeRequest(endpoint, { ...options, method: "GET" }); } async post(endpoint, data, options = {}) { return this.makeRequest(endpoint, { ...options, - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json', - ...options.headers + "Content-Type": "application/json", + ...options.headers, }, - body: JSON.stringify(data) + body: JSON.stringify(data), }); } async postFormData(endpoint, formData, options = {}) { if (this.isElectron) { - // For Electron, we need to handle file uploads differently - // Since we can't easily pass files through IPC, we'll fall back to fetch - const url = `${this.baseUrl}${endpoint}`; - const response = await fetch(url, { - ...options, - method: 'POST', - body: formData - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return await response.json(); - } else { - // For web, use FormData directly - const url = `${this.baseUrl}${endpoint}`; - const response = await fetch(url, { - ...options, - method: 'POST', - body: formData - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + try { + const response = await fetch(`${this.baseUrl}${endpoint}`, { + ...options, + method: "POST", + body: formData, + }); + const data = await this.parseJsonResponse(response); + this.setConnectionStatus("up"); + return data; + } catch (error) { + const status = error instanceof TypeError ? "down" : "error"; + this.setConnectionStatus(status, error?.message || "API request failed"); + throw error; } - - return await response.json(); } + + return this.requestWithFallback(endpoint, { + ...options, + method: "POST", + body: formData, + }); } } -// Export a singleton instance -export default new ApiClient(); +const apiClient = new ApiClient(); +export default apiClient;