diff --git a/frontend/src/core/components/FileManager.tsx b/frontend/src/core/components/FileManager.tsx index 0b56ddbb6c..ad7e44680e 100644 --- a/frontend/src/core/components/FileManager.tsx +++ b/frontend/src/core/components/FileManager.tsx @@ -18,8 +18,10 @@ import { import { loadScript } from "@app/utils/scriptLoader"; import { useAllFiles } from "@app/contexts/FileContext"; +import { ToolRegistryEntry } from "@app/data/toolsTaxonomy"; + interface FileManagerProps { - selectedTool?: Tool | null; + selectedTool?: Tool | ToolRegistryEntry | null; } const FileManager: React.FC = ({ selectedTool }) => { diff --git a/frontend/src/core/components/layout/Workbench.tsx b/frontend/src/core/components/layout/Workbench.tsx index 3650368444..9801fa8e7a 100644 --- a/frontend/src/core/components/layout/Workbench.tsx +++ b/frontend/src/core/components/layout/Workbench.tsx @@ -1,5 +1,5 @@ import { Suspense, lazy, useCallback } from "react"; -import { Box, Loader, Center } from "@mantine/core"; +import { Box } from "@mantine/core"; import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { useFileHandler } from "@app/hooks/useFileHandler"; @@ -20,6 +20,8 @@ import LandingPage from "@app/components/shared/LandingPage"; import Footer from "@app/components/shared/Footer"; import DismissAllErrorsButton from "@app/components/shared/DismissAllErrorsButton"; +import SkeletonLoader from "@app/components/shared/SkeletonLoader"; + // Workbench panels are loaded on demand. Viewer pulls in pdfjs-dist and the // full @embedpdf plugin set; FileEditor/PageEditor are only needed once a file // is open. Lazy-loading keeps all of that out of the initial bundle. @@ -244,9 +246,19 @@ export default function Workbench() { > - - + + + } > {renderMainContent()} diff --git a/frontend/src/core/components/shared/LoadingFallback.tsx b/frontend/src/core/components/shared/LoadingFallback.tsx index dced852150..0bf752c2c2 100644 --- a/frontend/src/core/components/shared/LoadingFallback.tsx +++ b/frontend/src/core/components/shared/LoadingFallback.tsx @@ -1,19 +1,25 @@ /** - * Loading fallback component for i18next suspense + * Loading fallback component for i18next suspense and lazy components. + * Uses 100% height to fill its parent container without causing layout shifts + * by forcing a 100vh height in contained areas. */ export function LoadingFallback() { return (
- Loading... +
Loading...
); } diff --git a/frontend/src/core/components/shared/SkeletonLoader.tsx b/frontend/src/core/components/shared/SkeletonLoader.tsx index 55cab42e47..1bdf86f20d 100644 --- a/frontend/src/core/components/shared/SkeletonLoader.tsx +++ b/frontend/src/core/components/shared/SkeletonLoader.tsx @@ -2,7 +2,7 @@ import React from "react"; import { Box, Group, Stack } from "@mantine/core"; interface SkeletonLoaderProps { - type: "pageGrid" | "fileGrid" | "controls" | "viewer" | "block"; + type: "pageGrid" | "fileGrid" | "controls" | "viewer" | "block" | "toolList"; count?: number; animated?: boolean; width?: number | string; @@ -35,6 +35,36 @@ const SkeletonLoader: React.FC = ({ /> ); + const renderToolListSkeleton = () => ( + + {/* Search bar placeholder */} + + + {/* List items */} + {Array.from({ length: count }).map((_, i) => ( + + + + + ))} + + ); + const renderPageGridSkeleton = () => (
= ({ switch (type) { case "block": return renderBlock(); + case "toolList": + return renderToolListSkeleton(); case "pageGrid": return renderPageGridSkeleton(); case "fileGrid": diff --git a/frontend/src/core/components/tools/SearchResults.tsx b/frontend/src/core/components/tools/SearchResults.tsx index 738efd3a8b..7ed517872d 100644 --- a/frontend/src/core/components/tools/SearchResults.tsx +++ b/frontend/src/core/components/tools/SearchResults.tsx @@ -78,7 +78,7 @@ const SearchResults: React.FC = ({ ))} {/* Global spacer to allow scrolling past last row in search mode */} -
+
); }; diff --git a/frontend/src/core/components/tools/ToolPanel.tsx b/frontend/src/core/components/tools/ToolPanel.tsx index 8be8d84c45..bf89d49fc0 100644 --- a/frontend/src/core/components/tools/ToolPanel.tsx +++ b/frontend/src/core/components/tools/ToolPanel.tsx @@ -19,14 +19,17 @@ import { useRightRail } from "@app/contexts/RightRailContext"; import { Tooltip } from "@app/components/shared/Tooltip"; import "@app/components/tools/ToolPanel.css"; -// No props needed - component uses context +interface ToolPanelProps { + isMobile?: boolean; +} -export default function ToolPanel() { +export default function ToolPanel({ isMobile: isMobileProp }: ToolPanelProps) { const { t } = useTranslation(); const { isRainbowMode } = useRainbowThemeContext(); const { sidebarRefs } = useSidebarContext(); const { toolPanelRef, quickAccessRef, rightRailRef } = sidebarRefs; - const isMobile = useIsMobile(); + const isMobileHook = useIsMobile(); + const isMobile = isMobileProp ?? isMobileHook; const { leftPanelView, diff --git a/frontend/src/core/components/tools/ToolPanelSkeleton.tsx b/frontend/src/core/components/tools/ToolPanelSkeleton.tsx new file mode 100644 index 0000000000..f45760b713 --- /dev/null +++ b/frontend/src/core/components/tools/ToolPanelSkeleton.tsx @@ -0,0 +1,33 @@ +import SkeletonLoader from "@app/components/shared/SkeletonLoader"; +import { useIsMobile } from "@app/hooks/useIsMobile"; +import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; + +interface ToolPanelSkeletonProps { + isMobile?: boolean; +} + +export default function ToolPanelSkeleton({ + isMobile: isMobileProp, +}: ToolPanelSkeletonProps) { + const isMobileHook = useIsMobile(); + const isMobile = isMobileProp ?? isMobileHook; + const { isPanelVisible } = useToolWorkflow(); + + const width = isMobile ? "100%" : isPanelVisible ? "18.5rem" : "0"; + + return ( +
+ +
+ ); +} diff --git a/frontend/src/core/components/tools/ToolPicker.tsx b/frontend/src/core/components/tools/ToolPicker.tsx index 02ba7625a1..80cc4930ca 100644 --- a/frontend/src/core/components/tools/ToolPicker.tsx +++ b/frontend/src/core/components/tools/ToolPicker.tsx @@ -183,7 +183,7 @@ const ToolPicker = ({ {!quickSection && !allSection && } {/* bottom spacer to allow scrolling past the last row */} -
+
)} diff --git a/frontend/src/core/contexts/AppConfigContext.test.tsx b/frontend/src/core/contexts/AppConfigContext.test.tsx index eb630b7715..5529b40a6f 100644 --- a/frontend/src/core/contexts/AppConfigContext.test.tsx +++ b/frontend/src/core/contexts/AppConfigContext.test.tsx @@ -28,6 +28,25 @@ describe("AppConfigContext", () => { {children} ); + /** + * Helper to mock API responses for app-config and info-status + */ + const mockApiResponses = (config: any, delay = 0) => { + vi.mocked(apiClient.get).mockImplementation(async (url: string) => { + if (delay > 0) { + await new Promise((resolve) => setTimeout(resolve, delay)); + } + + if (url === "/api/v1/config/app-config") { + return { status: 200, data: config }; + } + if (url === "/api/v1/info/status") { + return { status: 200, data: { status: "UP" } }; + } + return { status: 404, data: {} }; + }); + }; + it("should fetch and provide app config on non-auth pages", async () => { const mockConfig = { enableLogin: false, @@ -35,10 +54,8 @@ describe("AppConfigContext", () => { languages: ["en-US", "en-GB"], }; - vi.mocked(apiClient.get).mockResolvedValueOnce({ - status: 200, - data: mockConfig, - } as any); + // Use a small delay to ensure we can catch the loading state + mockApiResponses(mockConfig, 10); const { result } = renderHook(() => useAppConfig(), { wrapper }); @@ -46,11 +63,14 @@ describe("AppConfigContext", () => { expect(result.current.loading).toBe(true); expect(result.current.config).toBeNull(); - await waitFor(() => { - expect(result.current.loading).toBe(false); - expect(result.current.config).toEqual(mockConfig); - expect(result.current.error).toBeNull(); - }); + await waitFor( + () => { + expect(result.current.loading).toBe(false); + expect(result.current.config).toEqual(mockConfig); + expect(result.current.error).toBeNull(); + }, + { timeout: 1000 }, + ); expect(apiClient.get).toHaveBeenCalledWith("/api/v1/config/app-config", { suppressErrorToast: true, @@ -80,7 +100,7 @@ describe("AppConfigContext", () => { const mockError = Object.assign(new Error("Unauthorized"), { response: { status: 401, data: {} }, }); - vi.mocked(apiClient.get).mockRejectedValueOnce(mockError); + vi.mocked(apiClient.get).mockRejectedValue(mockError); const { result } = renderHook(() => useAppConfig(), { wrapper }); @@ -95,11 +115,9 @@ describe("AppConfigContext", () => { const errorMessage = "Network error occurred"; const mockError = new Error(errorMessage); // Network errors don't have response property - // Mock rejection for all retry attempts (default is 3 attempts) - vi.mocked(apiClient.get) - .mockRejectedValueOnce(mockError) - .mockRejectedValueOnce(mockError) - .mockRejectedValueOnce(mockError); + // Mock rejection for all retry attempts (default is 0 retries in test if not specified, + // but the component might still catch it) + vi.mocked(apiClient.get).mockRejectedValue(mockError); const { result } = renderHook(() => useAppConfig(), { wrapper }); @@ -171,11 +189,18 @@ describe("AppConfigContext", () => { enableAnalytics: true, }; - // First call returns initial config - vi.mocked(apiClient.get).mockResolvedValueOnce({ - status: 200, - data: initialConfig, - } as any); + // Setup implementation to return different configs on subsequent calls + let callCount = 0; + vi.mocked(apiClient.get).mockImplementation(async (url: string) => { + if (url === "/api/v1/config/app-config") { + callCount++; + return { + status: 200, + data: callCount === 1 ? initialConfig : updatedConfig, + }; + } + return { status: 200, data: { status: "UP" } }; + }); const { result } = renderHook(() => useAppConfig(), { wrapper }); @@ -183,12 +208,6 @@ describe("AppConfigContext", () => { expect(result.current.config).toEqual(initialConfig); }); - // Setup second call for refetch - vi.mocked(apiClient.get).mockResolvedValueOnce({ - status: 200, - data: updatedConfig, - } as any); - // Trigger jwt-available event wrapped in act await act(async () => { window.dispatchEvent(new CustomEvent("jwt-available")); @@ -200,7 +219,8 @@ describe("AppConfigContext", () => { expect(result.current.config).toEqual(updatedConfig); }); - expect(apiClient.get).toHaveBeenCalledTimes(2); + // 2 logical fetches * 2 calls each = 4 total calls + expect(apiClient.get).toHaveBeenCalledTimes(4); }); it("should provide refetch function", async () => { @@ -209,10 +229,7 @@ describe("AppConfigContext", () => { appNameNavbar: "Test App", }; - vi.mocked(apiClient.get).mockResolvedValue({ - status: 200, - data: mockConfig, - } as any); + mockApiResponses(mockConfig); const { result } = renderHook(() => useAppConfig(), { wrapper }); @@ -225,7 +242,8 @@ describe("AppConfigContext", () => { await result.current.refetch(); }); - expect(apiClient.get).toHaveBeenCalledTimes(2); + // 2 logical fetches * 2 calls each = 4 total calls + expect(apiClient.get).toHaveBeenCalledTimes(4); }); it("should not fetch twice without force flag", async () => { @@ -233,10 +251,7 @@ describe("AppConfigContext", () => { enableLogin: false, }; - vi.mocked(apiClient.get).mockResolvedValue({ - status: 200, - data: mockConfig, - } as any); + mockApiResponses(mockConfig); const { result } = renderHook(() => useAppConfig(), { wrapper }); @@ -244,8 +259,8 @@ describe("AppConfigContext", () => { expect(result.current.config).toEqual(mockConfig); }); - // Should only be called once (no duplicate fetches) - expect(apiClient.get).toHaveBeenCalledTimes(1); + // Should only be called twice (one logical fetch = /app-config + /info/status) + expect(apiClient.get).toHaveBeenCalledTimes(2); }); it("should handle initial config prop", async () => { @@ -254,6 +269,8 @@ describe("AppConfigContext", () => { appNameNavbar: "Initial App", }; + mockApiResponses({ ...initialConfig, fromApi: true }); + const customWrapper = ({ children }: { children: ReactNode }) => ( {children} @@ -275,11 +292,7 @@ describe("AppConfigContext", () => { it("should use suppressErrorToast for all config requests", async () => { const mockConfig = { enableLogin: true }; - - vi.mocked(apiClient.get).mockResolvedValueOnce({ - status: 200, - data: mockConfig, - } as any); + mockApiResponses(mockConfig); renderHook(() => useAppConfig(), { wrapper }); diff --git a/frontend/src/core/contexts/AppConfigContext.tsx b/frontend/src/core/contexts/AppConfigContext.tsx index 021508c36c..f740b9e345 100644 --- a/frontend/src/core/contexts/AppConfigContext.tsx +++ b/frontend/src/core/contexts/AppConfigContext.tsx @@ -65,9 +65,20 @@ export const AppConfigProvider: React.FC = ({ const [error, setError] = useState(null); // Track how many times we've attempted to fetch. useRef avoids re-renders that can trigger loops. const fetchCountRef = React.useRef(0); - const [hasResolvedConfig, setHasResolvedConfig] = useState( + // Use a Ref for hasResolvedConfig to avoid re-creating fetchConfig when it changes. + // This prevents unnecessary re-renders and potential infinite loops in consumers. + const hasResolvedConfigRef = React.useRef( Boolean(initialConfig) && !isBlockingMode, ); + const [hasResolvedConfig, setHasResolvedConfigState] = useState( + hasResolvedConfigRef.current, + ); + + const setHasResolvedConfig = (val: boolean) => { + hasResolvedConfigRef.current = val; + setHasResolvedConfigState(val); + }; + const [loading, setLoading] = useState(!hasResolvedConfig); const onConfigLoadedRef = React.useRef(onConfigLoaded); @@ -87,7 +98,7 @@ export const AppConfigProvider: React.FC = ({ // Mark that we've attempted a fetch to prevent repeated auto-fetch loops fetchCountRef.current += 1; - const shouldBlockUI = !hasResolvedConfig || isBlockingMode; + const shouldBlockUI = !hasResolvedConfigRef.current || isBlockingMode; if (shouldBlockUI) { setLoading(true); } @@ -114,21 +125,24 @@ export const AppConfigProvider: React.FC = ({ console.log("[AppConfig] Fetching app config..."); } - // apiClient automatically adds JWT header if available via interceptors - // Always suppress error toast - we handle 401 errors locally - console.debug("[AppConfig] Fetching app config", { - attempt, - force, - path: window.location.pathname, - }); - const response = await apiClient.get( + // Background probe for status to warm up the connection/cache - don't await to avoid gating config + apiClient + .get("/api/v1/info/status", { + suppressErrorToast: true, + skipAuthRedirect: true, + } as any) + .catch(() => null); + + // Fetch app config + const configResponse = await apiClient.get( "/api/v1/config/app-config", { suppressErrorToast: true, skipAuthRedirect: true, } as any, ); - const data = response.data; + + const data = configResponse.data; console.debug("[AppConfig] Config fetched successfully:", data); console.debug( @@ -195,7 +209,7 @@ export const AppConfigProvider: React.FC = ({ setLoading(false); }, - [hasResolvedConfig, isBlockingMode, maxRetries, initialDelay], + [isBlockingMode, maxRetries, initialDelay], ); const { isAuthPage } = useJwtConfigSync(fetchConfig); diff --git a/frontend/src/core/pages/HomePage.css b/frontend/src/core/pages/HomePage.css index 14c723c406..6537ce00f1 100644 --- a/frontend/src/core/pages/HomePage.css +++ b/frontend/src/core/pages/HomePage.css @@ -33,12 +33,14 @@ height: 1.5rem; width: auto; flex-shrink: 0; + aspect-ratio: 1 / 1; } .mobile-brand-text { height: 1.5rem; width: auto; max-width: 100%; + aspect-ratio: 4 / 1; /* Wordmark usually has around 4:1 ratio */ } .mobile-toggle-buttons { @@ -54,14 +56,18 @@ .mobile-toggle-button { border: none; border-radius: 9999px; - padding: 0.4rem 0.9rem; - font-size: 0.8125rem; + padding: 0.6rem 1.25rem; + font-size: 0.875rem; font-weight: 600; color: var(--text-muted); background: transparent; transition: background 0.2s ease, color 0.2s ease; + min-height: 44px; + display: flex; + align-items: center; + justify-content: center; } .mobile-toggle-button:focus-visible { @@ -91,6 +97,8 @@ scrollbar-width: none; -ms-overflow-style: none; touch-action: pan-x pinch-zoom; + /* Prevent background peeking through during snaps */ + background-color: var(--bg-toolbar); } .mobile-slider::-webkit-scrollbar { @@ -102,9 +110,12 @@ width: 100%; height: 100%; scroll-snap-align: start; + scroll-snap-stop: always; display: flex; flex-direction: column; min-height: 0; + overflow: hidden; + position: relative; } .mobile-slide-content { diff --git a/frontend/src/core/pages/HomePage.tsx b/frontend/src/core/pages/HomePage.tsx index 2119c53233..f9d7c402c5 100644 --- a/frontend/src/core/pages/HomePage.tsx +++ b/frontend/src/core/pages/HomePage.tsx @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { + lazy, + Suspense, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { Group } from "@mantine/core"; @@ -17,19 +24,34 @@ import { import { useViewer } from "@app/contexts/ViewerContext"; import AppsIcon from "@mui/icons-material/AppsRounded"; -import ToolPanel from "@app/components/tools/ToolPanel"; -import Workbench from "@app/components/layout/Workbench"; import QuickAccessBar from "@app/components/shared/QuickAccessBar"; import RightRail from "@app/components/shared/RightRail"; -import FileManager from "@app/components/FileManager"; import LocalIcon from "@app/components/shared/LocalIcon"; import { useFilesModalContext } from "@app/contexts/FilesModalContext"; -import AppConfigModal from "@app/components/shared/AppConfigModalLazy"; import { getStartupNavigationAction } from "@app/utils/homePageNavigation"; import { HomePageExtensions } from "@app/components/home/HomePageExtensions"; +import { LoadingFallback } from "@app/components/shared/LoadingFallback"; +import ToolPanelSkeleton from "@app/components/tools/ToolPanelSkeleton"; import "@app/pages/HomePage.css"; +// Lazy load heavy components +const ToolPanel = lazy(() => import("@app/components/tools/ToolPanel")); +const Workbench = lazy(() => import("@app/components/layout/Workbench")); +const FileManager = lazy(() => import("@app/components/FileManager")); +const AppConfigModal = lazy( + () => import("@app/components/shared/AppConfigModal"), +); + +// Preload heavy components to avoid waterfall by starting imports as soon as HomePage loads +const preloadImports = () => { + import("@app/components/tools/ToolPanel"); + import("@app/components/layout/Workbench"); + import("@app/components/FileManager"); + import("@app/components/shared/AppConfigModal"); +}; +preloadImports(); + type MobileView = "tools" | "workbench"; export default function HomePage() { @@ -116,12 +138,33 @@ export default function HomePage() { setActiveMobileView(view); }, []); + // Sync slider position on mount and orientation changes + useEffect(() => { + if (isMobile) { + const container = sliderRef.current; + if (container) { + const offset = activeMobileView === "tools" ? 0 : container.offsetWidth; + container.scrollLeft = offset; + } + } + }, [isMobile]); + useEffect(() => { if (isMobile) { const container = sliderRef.current; if (container) { - isProgrammaticScroll.current = true; const offset = activeMobileView === "tools" ? 0 : container.offsetWidth; + + // Skip if already at the target position to avoid fighting with user swipes + if (Math.abs(container.scrollLeft - offset) < 10) { + // Force exact alignment if slightly off + if (container.scrollLeft !== offset) { + container.scrollLeft = offset; + } + return; + } + + isProgrammaticScroll.current = true; container.scrollTo({ left: offset, behavior: "smooth" }); // Re-enable scroll listener after animation completes @@ -135,7 +178,9 @@ export default function HomePage() { setActiveMobileView("tools"); const container = sliderRef.current; if (container) { - container.scrollTo({ left: 0, behavior: "auto" }); + if (container.scrollLeft !== 0) { + container.scrollTo({ left: 0, behavior: "auto" }); + } } }, [activeMobileView, isMobile]); @@ -281,7 +326,9 @@ export default function HomePage() { aria-label={t("home.mobile.toolsSlide", "Tool selection panel")} >
- + }> + +
- + }> + +
@@ -358,19 +407,31 @@ export default function HomePage() {
- - setConfigModalOpen(false)} - /> + + + + + setConfigModalOpen(false)} + /> +
) : ( - {!hideToolPanel && } - + {!hideToolPanel && ( + }> + + + )} + }> + + - + + + )}
diff --git a/frontend/src/core/styles/theme.css b/frontend/src/core/styles/theme.css index 46809c07a3..88eedf0cb4 100644 --- a/frontend/src/core/styles/theme.css +++ b/frontend/src/core/styles/theme.css @@ -800,12 +800,19 @@ } /* Smooth transitions for theme switching */ -* { +/* Targeted transitions for theme switching instead of global * rule */ +body, +#root, +.mobile-layout, +.mobile-slide, +.h-screen, +[class*="mantine-"] { transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease; } + /* ── PDF Link Overlay (viewer) ── */ :root { --link-hover-bg: rgba(10, 139, 255, 0.04); diff --git a/frontend/src/core/theme/mantineTheme.ts b/frontend/src/core/theme/mantineTheme.ts index ac36eb515f..a57b455e73 100644 --- a/frontend/src/core/theme/mantineTheme.ts +++ b/frontend/src/core/theme/mantineTheme.ts @@ -108,7 +108,8 @@ export const mantineTheme = createTheme({ styles: { root: { fontWeight: "var(--font-weight-medium)", - transition: "all 0.2s ease", + transition: + "background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease", }, }, variants: {