diff --git a/llm-fe/src/axiosApi.js b/llm-fe/src/axiosApi.js
index a143e20..2b79527 100644
--- a/llm-fe/src/axiosApi.js
+++ b/llm-fe/src/axiosApi.js
@@ -1,5 +1,6 @@
import axios from "axios";
-const Cookies = require("js-cookie");
+import Cookies from "js-cookie";
+import { redirectToAppPath } from "./llm-fe/platform/nativePlatform";
const baseURL = process.env.REACT_APP_BACKEND_REST_API_BASE_URL;
@@ -46,7 +47,8 @@ axiosInstance.interceptors.response.use(
error.response.status === 401 &&
originalRequest.url === baseURL + "/token/refresh/"
) {
- window.location.href = "/signin/";
+ // Hash-safe on Capacitor (#24); plain path on web BrowserRouter.
+ redirectToAppPath("/signin/");
//console.log('remove the local storage here')
return Promise.reject(error);
}
@@ -83,11 +85,11 @@ axiosInstance.interceptors.response.use(
});
} else {
console.log("Refresh token is expired");
- window.location.href = "/signin/";
+ redirectToAppPath("/signin/");
}
} else {
console.log("Refresh token not available");
- window.location.href = "/signin/";
+ redirectToAppPath("/signin/");
}
}
return Promise.reject(error);
diff --git a/llm-fe/src/index.tsx b/llm-fe/src/index.tsx
index 1fa274d..55743fa 100644
--- a/llm-fe/src/index.tsx
+++ b/llm-fe/src/index.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
-import { BrowserRouter } from 'react-router-dom';
+import AppRouter from './llm-fe/platform/AppRouter';
import { AuthProvider } from './llm-fe/contexts/AuthContext';
import { AccountProvider } from './llm-fe/contexts/AccountContext';
import { WebSocketProvider } from './llm-fe/contexts/WebSocketContext';
@@ -16,7 +16,7 @@ const root = ReactDOM.createRoot(
);
root.render(
-
+
@@ -33,6 +33,6 @@ root.render(
-
+
);
diff --git a/llm-fe/src/llm-fe/platform/AppRouter.test.tsx b/llm-fe/src/llm-fe/platform/AppRouter.test.tsx
new file mode 100644
index 0000000..e6e29d3
--- /dev/null
+++ b/llm-fe/src/llm-fe/platform/AppRouter.test.tsx
@@ -0,0 +1,63 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { Route, Routes } from 'react-router-dom';
+import AppRouter from './AppRouter';
+import { getAppRouter, isNativePlatform } from './nativePlatform';
+
+describe('AppRouter / getAppRouter (#24)', () => {
+ const originalCapacitor = window.Capacitor;
+
+ afterEach(() => {
+ if (originalCapacitor === undefined) {
+ delete window.Capacitor;
+ } else {
+ window.Capacitor = originalCapacitor;
+ }
+ });
+
+ it('uses BrowserRouter on web', () => {
+ delete window.Capacitor;
+ expect(isNativePlatform()).toBe(false);
+ const Router = getAppRouter();
+ expect(Router.name).toBe('BrowserRouter');
+ });
+
+ it('uses HashRouter on native Capacitor', () => {
+ window.Capacitor = { isNativePlatform: () => true };
+ expect(isNativePlatform()).toBe(true);
+ const Router = getAppRouter();
+ expect(Router.name).toBe('HashRouter');
+ });
+
+ it('renders nested routes under HashRouter without blank screen', () => {
+ window.Capacitor = { isNativePlatform: () => true };
+ window.location.hash = '#/account/';
+
+ render(
+
+
+ Account Page} />
+ Home} />
+
+ ,
+ );
+
+ expect(screen.getByText('Account Page')).toBeInTheDocument();
+ });
+
+ it('renders nested routes under BrowserRouter on web', () => {
+ delete window.Capacitor;
+ window.history.pushState({}, '', '/account/');
+
+ render(
+
+
+ Account Page} />
+ Home} />
+
+ ,
+ );
+
+ expect(screen.getByText('Account Page')).toBeInTheDocument();
+ });
+});
diff --git a/llm-fe/src/llm-fe/platform/AppRouter.tsx b/llm-fe/src/llm-fe/platform/AppRouter.tsx
new file mode 100644
index 0000000..cdec648
--- /dev/null
+++ b/llm-fe/src/llm-fe/platform/AppRouter.tsx
@@ -0,0 +1,17 @@
+import React, { ReactNode } from 'react';
+import { getAppRouter } from './nativePlatform';
+
+type AppRouterProps = {
+ children: ReactNode;
+};
+
+/**
+ * Single place that chooses BrowserRouter (web) vs HashRouter (Capacitor).
+ * Avoids sprinkling Capacitor.isNativePlatform() through the route tree (#24).
+ */
+const AppRouter = ({ children }: AppRouterProps): JSX.Element => {
+ const Router = getAppRouter();
+ return {children};
+};
+
+export default AppRouter;
diff --git a/llm-fe/src/llm-fe/platform/nativePlatform.js b/llm-fe/src/llm-fe/platform/nativePlatform.js
new file mode 100644
index 0000000..a976e3d
--- /dev/null
+++ b/llm-fe/src/llm-fe/platform/nativePlatform.js
@@ -0,0 +1,65 @@
+/**
+ * Capacitor / WebView platform helpers (#24 / #22).
+ * Prefer window.Capacitor so unit tests and web builds need no Capacitor package.
+ */
+
+/**
+ * @returns {boolean}
+ */
+export function isNativePlatform() {
+ if (typeof window === "undefined") {
+ return false;
+ }
+ const Cap = window.Capacitor;
+ if (!Cap) {
+ return false;
+ }
+ if (typeof Cap.isNativePlatform === "function") {
+ return Cap.isNativePlatform();
+ }
+ return Cap.isNative === true;
+}
+
+/**
+ * Absolute in-app path for full page navigations.
+ * Native Capacitor builds use HashRouter; web keeps BrowserRouter paths.
+ *
+ * @param {string} path e.g. "/signin/"
+ * @returns {string}
+ */
+export function appHref(path) {
+ const normalized = path.startsWith("/") ? path : `/${path}`;
+ if (isNativePlatform()) {
+ return `/#${normalized}`;
+ }
+ return normalized;
+}
+
+/**
+ * Navigate the top-level window to an in-app route (auth failure, forced logout).
+ * @param {string} path
+ */
+export function redirectToAppPath(path) {
+ if (typeof window === "undefined") {
+ return;
+ }
+ if (isNativePlatform()) {
+ const normalized = path.startsWith("/") ? path : `/${path}`;
+ window.location.hash = `#${normalized}`;
+ return;
+ }
+ window.location.href = appHref(path);
+}
+
+/**
+ * Router class for the current runtime.
+ * HashRouter on Capacitor so nested routes reload without a server rewrite.
+ * BrowserRouter on web (nginx already rewrites to index.html).
+ *
+ * @returns {typeof import('react-router-dom').HashRouter | typeof import('react-router-dom').BrowserRouter}
+ */
+export function getAppRouter() {
+ // Lazy require keeps this module usable from axios (no React import cycle in tests).
+ const { BrowserRouter, HashRouter } = require("react-router-dom");
+ return isNativePlatform() ? HashRouter : BrowserRouter;
+}
diff --git a/llm-fe/src/llm-fe/platform/nativePlatform.test.js b/llm-fe/src/llm-fe/platform/nativePlatform.test.js
new file mode 100644
index 0000000..80ebe68
--- /dev/null
+++ b/llm-fe/src/llm-fe/platform/nativePlatform.test.js
@@ -0,0 +1,92 @@
+import {
+ appHref,
+ isNativePlatform,
+ redirectToAppPath,
+} from './nativePlatform';
+
+describe('nativePlatform', () => {
+ const originalCapacitor = window.Capacitor;
+ const originalLocation = window.location;
+
+ afterEach(() => {
+ if (originalCapacitor === undefined) {
+ delete window.Capacitor;
+ } else {
+ window.Capacitor = originalCapacitor;
+ }
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ value: originalLocation,
+ });
+ });
+
+ describe('isNativePlatform', () => {
+ it('returns false when Capacitor missing', () => {
+ delete window.Capacitor;
+ expect(isNativePlatform()).toBe(false);
+ });
+
+ it('uses Capacitor.isNativePlatform when present', () => {
+ window.Capacitor = {
+ isNativePlatform: () => true,
+ };
+ expect(isNativePlatform()).toBe(true);
+ });
+
+ it('falls back to Capacitor.isNative', () => {
+ window.Capacitor = { isNative: true };
+ expect(isNativePlatform()).toBe(true);
+ });
+ });
+
+ describe('appHref', () => {
+ it('returns plain path on web', () => {
+ delete window.Capacitor;
+ expect(appHref('/signin/')).toBe('/signin/');
+ });
+
+ it('returns hash path on native', () => {
+ window.Capacitor = { isNativePlatform: () => true };
+ expect(appHref('/signin/')).toBe('/#/signin/');
+ });
+ });
+
+ describe('redirectToAppPath', () => {
+ it('sets location.href on web', () => {
+ delete window.Capacitor;
+ const hrefSetter = jest.fn();
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ value: {
+ ...originalLocation,
+ set href(v) {
+ hrefSetter(v);
+ },
+ get href() {
+ return '';
+ },
+ },
+ });
+ redirectToAppPath('/signin/');
+ expect(hrefSetter).toHaveBeenCalledWith('/signin/');
+ });
+
+ it('sets location.hash on native', () => {
+ window.Capacitor = { isNativePlatform: () => true };
+ let hashValue = '';
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ value: {
+ get hash() {
+ return hashValue;
+ },
+ set hash(v) {
+ hashValue = v;
+ },
+ },
+ });
+ redirectToAppPath('/signin/');
+ expect(hashValue).toBe('#/signin/');
+ });
+ });
+});
diff --git a/llm-fe/src/react-app-env.d.ts b/llm-fe/src/react-app-env.d.ts
index 6431bc5..2e4afca 100644
--- a/llm-fe/src/react-app-env.d.ts
+++ b/llm-fe/src/react-app-env.d.ts
@@ -1 +1,19 @@
///
+
+interface CapacitorBridge {
+ isNativePlatform?: () => boolean;
+ isNative?: boolean;
+ Plugins?: {
+ App?: { addListener?: (...args: unknown[]) => unknown };
+ Network?: { addListener?: (...args: unknown[]) => unknown };
+ Preferences?: {
+ get?: (options: { key: string }) => Promise<{ value?: string | null }>;
+ set?: (options: { key: string; value: string }) => Promise;
+ remove?: (options: { key: string }) => Promise;
+ };
+ };
+}
+
+interface Window {
+ Capacitor?: CapacitorBridge;
+}