diff --git a/SparkyFitnessFrontend/eslint-rules/noHardcodedBasePathSelectors.cjs b/SparkyFitnessFrontend/eslint-rules/noHardcodedBasePathSelectors.cjs
new file mode 100644
index 0000000000..7bf479b53a
--- /dev/null
+++ b/SparkyFitnessFrontend/eslint-rules/noHardcodedBasePathSelectors.cjs
@@ -0,0 +1,52 @@
+// Shared `no-restricted-syntax` selectors flagging a hardcoded,
+// domain-root-absolute path used directly as a JSX `src=`/`href=` value or as
+// a `fetch(...)` argument. Imported by both eslint.config.js (the rule) and
+// src/tests/eslint/noHardcodedBasePath.test.ts (the test), so the two stay in
+// sync automatically instead of drifting apart. Written as CommonJS (.cjs) so
+// it loads cleanly from both eslint.config.js's native ESM `import` (Node
+// synthesizes named exports from `exports.x = ...` via cjs-module-lexer) and
+// Jest's CommonJS test runtime (plain `require()`, no transform needed).
+exports.noHardcodedBasePathSelectors = [
+ {
+ selector:
+ 'JSXAttribute[name.name=/^(src|href)$/] > Literal[value=/^\\/(?!\\/)/]',
+ message:
+ 'Hardcoded absolute path bypasses SPARKY_BASE_PATH. Wrap it with withBasePath() from @/utils/basePath.',
+ },
+ {
+ selector:
+ 'JSXAttribute[name.name=/^(src|href)$/] > JSXExpressionContainer > Literal[value=/^\\/(?!\\/)/]',
+ message:
+ 'Hardcoded absolute path bypasses SPARKY_BASE_PATH. Wrap it with withBasePath() from @/utils/basePath.',
+ },
+ {
+ selector:
+ 'JSXAttribute[name.name=/^(src|href)$/] > JSXExpressionContainer > TemplateLiteral > TemplateElement:first-child[value.raw=/^\\/(?!\\/)/]',
+ message:
+ 'Hardcoded absolute path bypasses SPARKY_BASE_PATH. Wrap it with withBasePath() from @/utils/basePath.',
+ },
+ {
+ selector:
+ 'CallExpression[callee.name="fetch"] > Literal[value=/^\\/(?!\\/)/]',
+ message:
+ 'Hardcoded absolute path bypasses SPARKY_BASE_PATH. Wrap it with withBasePath() from @/utils/basePath.',
+ },
+ {
+ selector:
+ 'CallExpression[callee.name="fetch"] > TemplateLiteral > TemplateElement:first-child[value.raw=/^\\/(?!\\/)/]',
+ message:
+ 'Hardcoded absolute path bypasses SPARKY_BASE_PATH. Wrap it with withBasePath() from @/utils/basePath.',
+ },
+ {
+ selector:
+ 'CallExpression[callee.type="MemberExpression"][callee.property.name="fetch"] > Literal[value=/^\\/(?!\\/)/]',
+ message:
+ 'Hardcoded absolute path bypasses SPARKY_BASE_PATH. Wrap it with withBasePath() from @/utils/basePath.',
+ },
+ {
+ selector:
+ 'CallExpression[callee.type="MemberExpression"][callee.property.name="fetch"] > TemplateLiteral > TemplateElement:first-child[value.raw=/^\\/(?!\\/)/]',
+ message:
+ 'Hardcoded absolute path bypasses SPARKY_BASE_PATH. Wrap it with withBasePath() from @/utils/basePath.',
+ },
+];
diff --git a/SparkyFitnessFrontend/eslint-rules/noHardcodedBasePathSelectors.d.cts b/SparkyFitnessFrontend/eslint-rules/noHardcodedBasePathSelectors.d.cts
new file mode 100644
index 0000000000..b4c5621eff
--- /dev/null
+++ b/SparkyFitnessFrontend/eslint-rules/noHardcodedBasePathSelectors.d.cts
@@ -0,0 +1,6 @@
+export interface NoHardcodedBasePathSelector {
+ selector: string;
+ message: string;
+}
+
+export const noHardcodedBasePathSelectors: NoHardcodedBasePathSelector[];
diff --git a/SparkyFitnessFrontend/eslint.config.js b/SparkyFitnessFrontend/eslint.config.js
index b45445ef60..6ab1ac130e 100644
--- a/SparkyFitnessFrontend/eslint.config.js
+++ b/SparkyFitnessFrontend/eslint.config.js
@@ -4,6 +4,7 @@ import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
import unusedImports from 'eslint-plugin-unused-imports';
+import { noHardcodedBasePathSelectors } from './eslint-rules/noHardcodedBasePathSelectors.cjs';
export default tseslint.config(
{ ignores: ['dist', 'build', 'coverage', 'node_modules'] },
@@ -131,5 +132,12 @@ export default tseslint.config(
rules: {
'react-refresh/only-export-components': 'off',
},
+ },
+ {
+ files: ['src/**/*.{ts,tsx}'],
+ ignores: ['src/tests/**'],
+ rules: {
+ 'no-restricted-syntax': ['error', ...noHardcodedBasePathSelectors],
+ },
}
);
diff --git a/SparkyFitnessFrontend/index.html b/SparkyFitnessFrontend/index.html
index 1df9e6a706..60a7f6d1b2 100644
--- a/SparkyFitnessFrontend/index.html
+++ b/SparkyFitnessFrontend/index.html
@@ -1,6 +1,7 @@
+
SparkyFitness
-
-
+
+
-
-
+
-
+
diff --git a/SparkyFitnessFrontend/public/manifest.json b/SparkyFitnessFrontend/public/manifest.json
deleted file mode 100644
index b923ed8d69..0000000000
--- a/SparkyFitnessFrontend/public/manifest.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "name": "SparkyFitness",
- "short_name": "SparkyFit",
- "description": "Your Personal Fitness Companion",
- "start_url": "/",
- "display": "standalone",
- "background_color": "#ffffff",
- "theme_color": "#000000",
- "icons": [
- {
- "src": "/images/icons/icon-192x192.png",
- "sizes": "192x192",
- "type": "image/png"
- },
- {
- "src": "/images/icons/icon-512x512.png",
- "sizes": "512x512",
- "type": "image/png"
- }
- ]
-}
diff --git a/SparkyFitnessFrontend/src/App.tsx b/SparkyFitnessFrontend/src/App.tsx
index fc9a0cb7ee..55218c518c 100644
--- a/SparkyFitnessFrontend/src/App.tsx
+++ b/SparkyFitnessFrontend/src/App.tsx
@@ -45,6 +45,7 @@ import {
import { error as logError } from '@/utils/logging';
import { getUserLoggingLevel } from '@/utils/userPreferences.ts';
import { lazyWithChunkRecovery } from '@/utils/chunkRecovery';
+import { getRouterBasename } from '@/utils/basePath';
const Auth = lazyWithChunkRecovery(() => import('@/pages/Auth/Auth'));
const ForgotPassword = lazyWithChunkRecovery(
() => import('@/pages/Auth/ForgotPassword')
@@ -300,7 +301,7 @@ const ReportsWrapper = () => {
return ;
};
-const router = createBrowserRouter([
+const routes = [
{
Component: Root,
ErrorBoundary: RootErrorBoundary,
@@ -454,7 +455,11 @@ const router = createBrowserRouter([
{ path: '*', Component: NotFound },
],
},
-]);
+];
+
+const router = createBrowserRouter(routes, {
+ basename: getRouterBasename(),
+});
const App = () => {
return (
diff --git a/SparkyFitnessFrontend/src/api/Chatbot/sparkyChatService.ts b/SparkyFitnessFrontend/src/api/Chatbot/sparkyChatService.ts
index bd5a4ba3ed..0d10bc1811 100644
--- a/SparkyFitnessFrontend/src/api/Chatbot/sparkyChatService.ts
+++ b/SparkyFitnessFrontend/src/api/Chatbot/sparkyChatService.ts
@@ -1,4 +1,4 @@
-import { apiCall } from '@/api/api';
+import { apiCall, API_BASE_URL } from '@/api/api';
import { error } from '@/utils/logging';
import { Message } from '@/types/Chatbot_types';
@@ -12,6 +12,8 @@ interface ChatHistory extends Message {
created_at: string;
}
+export const getChatStreamUrl = (): string => `${API_BASE_URL}/chat/stream`;
+
export const loadUserPreferences = async (): Promise => {
const data = await apiCall(`/user-preferences`, {
method: 'GET',
diff --git a/SparkyFitnessFrontend/src/api/Exercises/exerciseService.ts b/SparkyFitnessFrontend/src/api/Exercises/exerciseService.ts
index ffb021c980..d6d5746eb1 100644
--- a/SparkyFitnessFrontend/src/api/Exercises/exerciseService.ts
+++ b/SparkyFitnessFrontend/src/api/Exercises/exerciseService.ts
@@ -1,4 +1,5 @@
import { apiCall } from '@/api/api';
+import { withBasePath } from '@/utils/basePath';
import { ExerciseCSVData } from '@/pages/Exercises/ExerciseImportCSV';
import {
Exercise,
@@ -280,7 +281,7 @@ export const importFitFiles = async (
};
export const getBodyMapSvg = async (): Promise => {
- const response = await fetch('/images/muscle-male.svg');
+ const response = await fetch(withBasePath('/images/muscle-male.svg'));
if (!response.ok) {
throw new Error('Failed to fetch SVG');
}
diff --git a/SparkyFitnessFrontend/src/api/api.ts b/SparkyFitnessFrontend/src/api/api.ts
index 1807f64959..8ea9ecddc7 100644
--- a/SparkyFitnessFrontend/src/api/api.ts
+++ b/SparkyFitnessFrontend/src/api/api.ts
@@ -1,6 +1,7 @@
import { toast } from '@/hooks/use-toast';
import * as logging from '@/utils/logging';
import { getUserLoggingLevel } from '@/utils/userPreferences';
+import { getBasePath } from '@/utils/basePath';
interface ApiCallOptions extends RequestInit {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -15,7 +16,7 @@ interface ApiCallOptions extends RequestInit {
class HttpApiError extends Error {}
-export const API_BASE_URL = '/api';
+export const API_BASE_URL = `${getBasePath()}/api`;
//export const API_BASE_URL = 'http://192.168.1.111:3010';
// A single-use guard so a reload triggered by gateway interception (see
diff --git a/SparkyFitnessFrontend/src/components/DraggableChatbotButton.tsx b/SparkyFitnessFrontend/src/components/DraggableChatbotButton.tsx
index 1de9cdeb76..20f1d1cf5b 100644
--- a/SparkyFitnessFrontend/src/components/DraggableChatbotButton.tsx
+++ b/SparkyFitnessFrontend/src/components/DraggableChatbotButton.tsx
@@ -5,6 +5,7 @@ import { useChatbotVisibility } from '@/contexts/ChatbotVisibilityContext';
import { useIsMobile } from '@/hooks/use-mobile';
import { useAuth } from '@/hooks/useAuth';
import { useActiveAIService } from '@/hooks/AI/useAIServiceSettings';
+import { withBasePath } from '@/utils/basePath';
const BUTTON_SIZE = 56; // 14 * 4 = 56px (w-14)
const MINIMIZED_SIZE = 24;
@@ -378,7 +379,7 @@ const DraggableChatbotButton: React.FC = () => {
)}
{
const { t } = useTranslation();
diff --git a/SparkyFitnessFrontend/src/i18n.ts b/SparkyFitnessFrontend/src/i18n.ts
index 67c466d8f8..b01df7c4c7 100644
--- a/SparkyFitnessFrontend/src/i18n.ts
+++ b/SparkyFitnessFrontend/src/i18n.ts
@@ -3,6 +3,11 @@ import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import HttpApi from 'i18next-http-backend';
import { getSupportedLanguages } from './utils/languageUtils';
+import { withBasePath } from './utils/basePath';
+
+export function getLocalesLoadPath(): string {
+ return withBasePath('/locales/{{lng}}/{{ns}}.json');
+}
i18n
.use(HttpApi)
@@ -23,7 +28,7 @@ i18n
caches: ['localStorage', 'cookie'],
},
backend: {
- loadPath: '/locales/{{lng}}/{{ns}}.json',
+ loadPath: getLocalesLoadPath(),
},
react: {
useSuspense: false,
diff --git a/SparkyFitnessFrontend/src/layouts/MainLayout.tsx b/SparkyFitnessFrontend/src/layouts/MainLayout.tsx
index a0847c2f9e..13848df33d 100644
--- a/SparkyFitnessFrontend/src/layouts/MainLayout.tsx
+++ b/SparkyFitnessFrontend/src/layouts/MainLayout.tsx
@@ -44,6 +44,7 @@ import { useCurrentVersionQuery } from '@/hooks/useGeneralQueries';
import { useCycleSettings } from '@/hooks/useCycle';
import { cn } from '@/lib/utils';
import { getGridClassNormal } from '@/utils/layout';
+import { withBasePath } from '@/utils/basePath';
interface AddCompItem {
value: string;
@@ -439,7 +440,7 @@ const MainLayout: React.FC = ({

{
const navigate = useNavigate();
@@ -415,7 +416,7 @@ const Auth = () => {

@@ -514,12 +515,12 @@ const Auth = () => {
/>