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 = () => { )} AI Chatbot { 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 = ({
SparkyFitness Logo { const navigate = useNavigate(); @@ -415,7 +416,7 @@ const Auth = () => {
SparkyFitness Logo @@ -514,12 +515,12 @@ const Auth = () => { />