-

+
The Free Adobe Acrobat Alternative
diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx
index 3d313cd68..88093d84b 100644
--- a/frontend/src/core/components/AppProviders.tsx
+++ b/frontend/src/core/components/AppProviders.tsx
@@ -1,4 +1,4 @@
-import { ReactNode } from "react";
+import { ReactNode, useEffect } from "react";
import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvider";
import { FileContextProvider } from "@app/contexts/FileContext";
import { NavigationProvider } from "@app/contexts/NavigationContext";
@@ -21,6 +21,7 @@ import { CookieConsentProvider } from "@app/contexts/CookieConsentContext";
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
import { useScarfTracking } from "@app/hooks/useScarfTracking";
import { useAppInitialization } from "@app/hooks/useAppInitialization";
+import { useLogoAssets } from '@app/hooks/useLogoAssets';
// Component to initialize scarf tracking (must be inside AppConfigProvider)
function ScarfTrackingInitializer() {
@@ -34,6 +35,30 @@ function AppInitializer() {
return null;
}
+function BrandingAssetManager() {
+ const { favicon, logo192, manifestHref } = useLogoAssets();
+
+ useEffect(() => {
+ if (typeof document === 'undefined') {
+ return;
+ }
+
+ const setLinkHref = (selector: string, href: string) => {
+ const link = document.querySelector
(selector);
+ if (link && link.getAttribute('href') !== href) {
+ link.setAttribute('href', href);
+ }
+ };
+
+ setLinkHref('link[rel="icon"]', favicon);
+ setLinkHref('link[rel="shortcut icon"]', favicon);
+ setLinkHref('link[rel="apple-touch-icon"]', logo192);
+ setLinkHref('link[rel="manifest"]', manifestHref);
+ }, [favicon, logo192, manifestHref]);
+
+ return null;
+}
+
// Avoid requirement to have props which are required in app providers anyway
type AppConfigProviderOverrides = Omit;
@@ -62,6 +87,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
+
diff --git a/frontend/src/core/components/fileEditor/AddFileCard.tsx b/frontend/src/core/components/fileEditor/AddFileCard.tsx
index 4cc3741d3..a4a549d82 100644
--- a/frontend/src/core/components/fileEditor/AddFileCard.tsx
+++ b/frontend/src/core/components/fileEditor/AddFileCard.tsx
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import AddIcon from '@mui/icons-material/Add';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import LocalIcon from '@app/components/shared/LocalIcon';
-import { BASE_PATH } from '@app/constants/app';
+import { useLogoAssets } from '@app/hooks/useLogoAssets';
import styles from '@app/components/fileEditor/FileEditor.module.css';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
@@ -25,6 +25,7 @@ const AddFileCard = ({
const { openFilesModal } = useFilesModalContext();
const { colorScheme } = useMantineColorScheme();
const [isUploadHover, setIsUploadHover] = useState(false);
+ const { wordmark } = useLogoAssets();
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
@@ -91,7 +92,7 @@ const AddFileCard = ({
{/* Stirling PDF Branding */}
diff --git a/frontend/src/core/components/fileManager/EmptyFilesState.tsx b/frontend/src/core/components/fileManager/EmptyFilesState.tsx
index ae46909d1..24e79fb12 100644
--- a/frontend/src/core/components/fileManager/EmptyFilesState.tsx
+++ b/frontend/src/core/components/fileManager/EmptyFilesState.tsx
@@ -4,7 +4,7 @@ import HistoryIcon from '@mui/icons-material/History';
import { useTranslation } from 'react-i18next';
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
import LocalIcon from '@app/components/shared/LocalIcon';
-import { BASE_PATH } from '@app/constants/app';
+import { useLogoAssets } from '@app/hooks/useLogoAssets';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
@@ -13,6 +13,7 @@ const EmptyFilesState: React.FC = () => {
const { colorScheme } = useMantineColorScheme();
const { onLocalFileClick } = useFileManagerContext();
const [isUploadHover, setIsUploadHover] = useState(false);
+ const { wordmark } = useLogoAssets();
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
@@ -57,7 +58,7 @@ const EmptyFilesState: React.FC = () => {
{/* Stirling PDF Logo */}
diff --git a/frontend/src/core/components/shared/AppConfigModal.css b/frontend/src/core/components/shared/AppConfigModal.css
index 2ada07184..5b006e5bf 100644
--- a/frontend/src/core/components/shared/AppConfigModal.css
+++ b/frontend/src/core/components/shared/AppConfigModal.css
@@ -127,4 +127,45 @@
display: flex;
justify-content: flex-end;
gap: 0.5rem;
+}
+
+/* Settings section container for sticky footer support */
+.settings-section-container {
+ display: flex;
+ flex-direction: column;
+ min-height: 100%;
+ position: relative;
+}
+
+.settings-section-content {
+ flex: 1;
+ padding-bottom: 5rem; /* Space for sticky footer */
+}
+
+/* Sticky footer for save/discard buttons */
+.settings-sticky-footer {
+ position: sticky;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: var(--modal-content-bg);
+ border-top: 1px solid var(--modal-header-border);
+ padding: 1rem 2rem;
+ margin: 0 -2rem;
+ margin-bottom: -1rem;
+ z-index: 10;
+ box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
+}
+
+/* Mobile adjustments */
+@media (max-width: 1024px) {
+ .settings-sticky-footer {
+ padding: 0.75rem 1rem;
+ margin: 0 -1rem;
+ margin-bottom: -1rem;
+ }
+
+ .settings-section-content {
+ padding-bottom: 4rem;
+ }
}
\ No newline at end of file
diff --git a/frontend/src/core/components/shared/AppConfigModal.tsx b/frontend/src/core/components/shared/AppConfigModal.tsx
index fe351c0ae..5a54e20e6 100644
--- a/frontend/src/core/components/shared/AppConfigModal.tsx
+++ b/frontend/src/core/components/shared/AppConfigModal.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo, useState, useEffect } from 'react';
+import React, { useMemo, useState, useEffect, useCallback } from 'react';
import { Modal, Text, ActionIcon, Tooltip, Group } from '@mantine/core';
import { useNavigate, useLocation } from 'react-router-dom';
import LocalIcon from '@app/components/shared/LocalIcon';
@@ -9,19 +9,21 @@ import '@app/components/shared/AppConfigModal.css';
import { useIsMobile } from '@app/hooks/useIsMobile';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useLicenseAlert } from '@app/hooks/useLicenseAlert';
+import { UnsavedChangesProvider, useUnsavedChanges } from '@app/contexts/UnsavedChangesContext';
interface AppConfigModalProps {
opened: boolean;
onClose: () => void;
}
-const AppConfigModal: React.FC = ({ opened, onClose }) => {
+const AppConfigModalInner: React.FC = ({ opened, onClose }) => {
const [active, setActive] = useState('general');
const isMobile = useIsMobile();
const navigate = useNavigate();
const location = useLocation();
const { config } = useAppConfig();
const licenseAlert = useLicenseAlert();
+ const { confirmIfDirty } = useUnsavedChanges();
// Extract section from URL path (e.g., /settings/people -> people)
const getSectionFromPath = (pathname: string): NavKey | null => {
@@ -97,11 +99,22 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => {
return null;
}, [configNavSections, active]);
- const handleClose = () => {
+ const handleClose = useCallback(async () => {
+ const canProceed = await confirmIfDirty();
+ if (!canProceed) return;
+
// Navigate back to home when closing modal
navigate('/', { replace: true });
onClose();
- };
+ }, [confirmIfDirty, navigate, onClose]);
+
+ const handleNavigation = useCallback(async (key: NavKey) => {
+ const canProceed = await confirmIfDirty();
+ if (!canProceed) return;
+
+ setActive(key);
+ navigate(`/settings/${key}`);
+ }, [confirmIfDirty, navigate]);
return (
= ({ opened, onClose }) => {
const navItemContent = (
{
- // Allow navigation even when disabled - the content inside will be disabled
- setActive(item.key);
- navigate(`/settings/${item.key}`);
- }}
+ onClick={() => handleNavigation(item.key)}
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
style={{
background: isActive ? colors.navItemActiveBg : 'transparent',
@@ -226,4 +235,13 @@ const AppConfigModal: React.FC
= ({ opened, onClose }) => {
);
};
+// Wrapper component that provides the UnsavedChangesContext
+const AppConfigModal: React.FC = (props) => {
+ return (
+
+
+
+ );
+};
+
export default AppConfigModal;
diff --git a/frontend/src/core/components/shared/LandingPage.tsx b/frontend/src/core/components/shared/LandingPage.tsx
index 46b75b7c5..68ee35537 100644
--- a/frontend/src/core/components/shared/LandingPage.tsx
+++ b/frontend/src/core/components/shared/LandingPage.tsx
@@ -5,8 +5,9 @@ import LocalIcon from '@app/components/shared/LocalIcon';
import { useTranslation } from 'react-i18next';
import { useFileHandler } from '@app/hooks/useFileHandler';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
-import { BASE_PATH } from '@app/constants/app';
import { useLogoPath } from '@app/hooks/useLogoPath';
+import { useLogoAssets } from '@app/hooks/useLogoAssets';
+import { useLogoVariant } from '@app/hooks/useLogoVariant';
import { useFileManager } from '@app/hooks/useFileManager';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
@@ -19,6 +20,8 @@ const LandingPage = () => {
const { openFilesModal } = useFilesModalContext();
const [isUploadHover, setIsUploadHover] = React.useState(false);
const logoPath = useLogoPath();
+ const logoVariant = useLogoVariant();
+ const { wordmark } = useLogoAssets();
const { loadRecentFiles } = useFileManager();
const [hasRecents, setHasRecents] = React.useState(false);
const terminology = useFileActionTerminology();
@@ -87,24 +90,25 @@ const LandingPage = () => {
},
}}
>
-
-

-
+ >
+
+
+ )}
{
{/* Stirling PDF Branding */}
diff --git a/frontend/src/core/components/shared/Tooltip.tsx b/frontend/src/core/components/shared/Tooltip.tsx
index 7e7ee5750..db6bce849 100644
--- a/frontend/src/core/components/shared/Tooltip.tsx
+++ b/frontend/src/core/components/shared/Tooltip.tsx
@@ -6,7 +6,7 @@ import { useTooltipPosition } from '@app/hooks/useTooltipPosition';
import { TooltipTip } from '@app/types/tips';
import { TooltipContent } from '@app/components/shared/tooltip/TooltipContent';
import { useSidebarContext } from '@app/contexts/SidebarContext';
-import { BASE_PATH } from '@app/constants/app';
+import { useLogoAssets } from '@app/hooks/useLogoAssets';
import styles from '@app/components/shared/tooltip/Tooltip.module.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
@@ -58,6 +58,7 @@ export const Tooltip: React.FC = ({
}) => {
const [internalOpen, setInternalOpen] = useState(false);
const [isPinned, setIsPinned] = useState(false);
+ const { tooltipLogo } = useLogoAssets();
const triggerRef = useRef(null);
const tooltipRef = useRef(null);
@@ -352,7 +353,7 @@ export const Tooltip: React.FC = ({
{header.logo || (

diff --git a/frontend/src/core/components/tools/FullscreenToolSurface.tsx b/frontend/src/core/components/tools/FullscreenToolSurface.tsx
index beebaefe0..2eedbf5f5 100644
--- a/frontend/src/core/components/tools/FullscreenToolSurface.tsx
+++ b/frontend/src/core/components/tools/FullscreenToolSurface.tsx
@@ -7,8 +7,8 @@ import FullscreenToolList from '@app/components/tools/FullscreenToolList';
import { ToolRegistryEntry } from '@app/data/toolsTaxonomy';
import { ToolId } from '@app/types/toolId';
import { useFocusTrap } from '@app/hooks/useFocusTrap';
-import { BASE_PATH } from '@app/constants/app';
import { useLogoPath } from '@app/hooks/useLogoPath';
+import { useLogoAssets } from '@app/hooks/useLogoAssets';
import { Tooltip } from '@app/components/shared/Tooltip';
import '@app/components/tools/ToolPanel.css';
import { ToolPanelGeometry } from '@app/hooks/tools/useToolPanelGeometry';
@@ -54,9 +54,8 @@ const FullscreenToolSurface = ({
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const brandIconSrc = useLogoPath();
- const brandTextSrc = `${BASE_PATH}/branding/StirlingPDFLogo${
- colorScheme === "dark" ? "White" : "Black"
- }Text.svg`;
+ const { wordmark } = useLogoAssets();
+ const brandTextSrc = colorScheme === "dark" ? wordmark.white : wordmark.black;
const handleExit = () => {
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
diff --git a/frontend/src/core/constants/logo.ts b/frontend/src/core/constants/logo.ts
new file mode 100644
index 000000000..7838f396b
--- /dev/null
+++ b/frontend/src/core/constants/logo.ts
@@ -0,0 +1,15 @@
+import type { LogoVariant } from '@app/services/preferencesService';
+
+export const LOGO_FOLDER_BY_VARIANT: Record
= {
+ modern: 'modern-logo',
+ classic: 'classic-logo',
+};
+
+export const ensureLogoVariant = (value?: string | null): LogoVariant => {
+ return value === 'classic' ? 'classic' : 'modern';
+};
+
+export const getLogoFolder = (variant?: LogoVariant | null): string => {
+ return LOGO_FOLDER_BY_VARIANT[ensureLogoVariant(variant)];
+};
+
diff --git a/frontend/src/core/contexts/UnsavedChangesContext.tsx b/frontend/src/core/contexts/UnsavedChangesContext.tsx
new file mode 100644
index 000000000..057239400
--- /dev/null
+++ b/frontend/src/core/contexts/UnsavedChangesContext.tsx
@@ -0,0 +1,95 @@
+import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
+import { Modal, Text, Button, Group, Stack } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+
+interface UnsavedChangesContextType {
+ isDirty: boolean;
+ setIsDirty: (dirty: boolean) => void;
+ /**
+ * Call this before navigating away or closing.
+ * Returns a promise that resolves to true if safe to proceed, false if blocked.
+ */
+ confirmIfDirty: () => Promise;
+ /**
+ * Reset dirty state (call after successful save)
+ */
+ markClean: () => void;
+}
+
+const UnsavedChangesContext = createContext(undefined);
+
+interface UnsavedChangesProviderProps {
+ children: ReactNode;
+}
+
+export function UnsavedChangesProvider({ children }: UnsavedChangesProviderProps) {
+ const { t } = useTranslation();
+ const [isDirty, setIsDirty] = useState(false);
+ const [modalOpen, setModalOpen] = useState(false);
+ const [resolvePromise, setResolvePromise] = useState<((value: boolean) => void) | null>(null);
+
+ const confirmIfDirty = useCallback((): Promise => {
+ if (!isDirty) {
+ return Promise.resolve(true);
+ }
+
+ return new Promise((resolve) => {
+ setResolvePromise(() => resolve);
+ setModalOpen(true);
+ });
+ }, [isDirty]);
+
+ const markClean = useCallback(() => {
+ setIsDirty(false);
+ }, []);
+
+ const handleDiscard = () => {
+ setModalOpen(false);
+ setIsDirty(false);
+ resolvePromise?.(true);
+ setResolvePromise(null);
+ };
+
+ const handleCancel = () => {
+ setModalOpen(false);
+ resolvePromise?.(false);
+ setResolvePromise(null);
+ };
+
+ return (
+
+ {children}
+
+
+
+ {t('admin.settings.unsavedChanges.message', 'You have unsaved changes. Do you want to discard them?')}
+
+
+
+
+
+
+
+
+ );
+}
+
+export function useUnsavedChanges(): UnsavedChangesContextType {
+ const context = useContext(UnsavedChangesContext);
+ if (!context) {
+ throw new Error('useUnsavedChanges must be used within an UnsavedChangesProvider');
+ }
+ return context;
+}
+
diff --git a/frontend/src/core/hooks/useLogoAssets.test.ts b/frontend/src/core/hooks/useLogoAssets.test.ts
new file mode 100644
index 000000000..3f36d262d
--- /dev/null
+++ b/frontend/src/core/hooks/useLogoAssets.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, test } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import { LOGO_FOLDER_BY_VARIANT } from '@app/constants/logo';
+import type { LogoVariant } from '@app/services/preferencesService';
+
+/**
+ * Tests that all required logo assets exist for each logo variant.
+ * This ensures that when useLogoAssets returns paths, those files actually exist.
+ */
+describe('useLogoAssets - Logo Asset Files', () => {
+ const publicDir = path.resolve(__dirname, '../../../public');
+
+ // All asset files that useLogoAssets references
+ const requiredAssets = [
+ 'logo-tooltip.svg',
+ 'Firstpage.png',
+ 'favicon.ico',
+ 'logo192.png',
+ 'logo512.png',
+ 'StirlingPDFLogoWhiteText.svg',
+ 'StirlingPDFLogoBlackText.svg',
+ 'StirlingPDFLogoGreyText.svg',
+ ];
+
+ const logoVariants: LogoVariant[] = ['modern', 'classic'];
+
+ describe.each(logoVariants)('%s logo variant', (variant) => {
+ const folder = LOGO_FOLDER_BY_VARIANT[variant];
+ const folderPath = path.join(publicDir, folder);
+
+ test(`folder "${folder}" should exist`, () => {
+ expect(fs.existsSync(folderPath)).toBe(true);
+ });
+
+ test.each(requiredAssets)('should have %s', (assetName) => {
+ const assetPath = path.join(folderPath, assetName);
+ expect(
+ fs.existsSync(assetPath),
+ `Missing asset: ${folder}/${assetName}`
+ ).toBe(true);
+ });
+ });
+
+ describe('manifest files', () => {
+ test('manifest.json should exist for modern variant', () => {
+ const manifestPath = path.join(publicDir, 'manifest.json');
+ expect(fs.existsSync(manifestPath)).toBe(true);
+ });
+
+ test('manifest-classic.json should exist for classic variant', () => {
+ const manifestPath = path.join(publicDir, 'manifest-classic.json');
+ expect(fs.existsSync(manifestPath)).toBe(true);
+ });
+ });
+});
+
diff --git a/frontend/src/core/hooks/useLogoAssets.ts b/frontend/src/core/hooks/useLogoAssets.ts
new file mode 100644
index 000000000..4d80b0809
--- /dev/null
+++ b/frontend/src/core/hooks/useLogoAssets.ts
@@ -0,0 +1,34 @@
+import { useMemo } from 'react';
+import { BASE_PATH } from '@app/constants/app';
+import { getLogoFolder } from '@app/constants/logo';
+import { useLogoVariant } from '@app/hooks/useLogoVariant';
+
+export function useLogoAssets() {
+ const logoVariant = useLogoVariant();
+
+ return useMemo(() => {
+ const folder = getLogoFolder(logoVariant);
+ const folderPath = `${BASE_PATH}/${folder}`;
+
+ return {
+ logoVariant,
+ folder,
+ folderPath,
+ getAssetPath: (name: string) => `${folderPath}/${name}`,
+ tooltipLogo: `${folderPath}/logo-tooltip.svg`,
+ firstPage: `${folderPath}/Firstpage.png`,
+ favicon: `${folderPath}/favicon.ico`,
+ logo192: `${folderPath}/logo192.png`,
+ logo512: `${folderPath}/logo512.png`,
+ wordmark: {
+ white: `${folderPath}/StirlingPDFLogoWhiteText.svg`,
+ black: `${folderPath}/StirlingPDFLogoBlackText.svg`,
+ grey: `${folderPath}/StirlingPDFLogoGreyText.svg`,
+ },
+ manifestHref: logoVariant === 'classic'
+ ? `${BASE_PATH}/manifest-classic.json`
+ : `${BASE_PATH}/manifest.json`,
+ };
+ }, [logoVariant]);
+}
+
diff --git a/frontend/src/core/hooks/useLogoPath.ts b/frontend/src/core/hooks/useLogoPath.ts
index db97f9c08..a4901334c 100644
--- a/frontend/src/core/hooks/useLogoPath.ts
+++ b/frontend/src/core/hooks/useLogoPath.ts
@@ -1,31 +1,22 @@
import { useMemo } from 'react';
-import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useMantineColorScheme } from '@mantine/core';
-import { BASE_PATH } from '@app/constants/app';
+import { useLogoAssets } from '@app/hooks/useLogoAssets';
/**
* Hook to get the correct logo path based on app config (logo style) and theme (light/dark)
*
* Logo styles:
- * - classic: branding/old/favicon.svg (classic S logo - default)
- * - modern: StirlingPDFLogoNoText{Light|Dark}.svg (minimalist modern design)
+ * - classic: classic S logo stored in /classic-logo
+ * - modern: minimalist logo stored in /modern-logo
*
* @returns The path to the appropriate logo SVG file
*/
export function useLogoPath(): string {
- const { config } = useAppConfig();
const { colorScheme } = useMantineColorScheme();
+ const { folderPath } = useLogoAssets();
return useMemo(() => {
- const logoStyle = config?.logoStyle || 'classic';
-
- if (logoStyle === 'classic') {
- // Classic logo (old favicon) - same for both light and dark modes
- return `${BASE_PATH}/branding/old/favicon.svg`;
- }
-
- // Modern logo - different for light and dark modes
const themeSuffix = colorScheme === 'dark' ? 'Dark' : 'Light';
- return `${BASE_PATH}/branding/StirlingPDFLogoNoText${themeSuffix}.svg`;
- }, [config?.logoStyle, colorScheme]);
+ return `${folderPath}/StirlingPDFLogoNoText${themeSuffix}.svg`;
+ }, [colorScheme, folderPath]);
}
diff --git a/frontend/src/core/hooks/useLogoVariant.ts b/frontend/src/core/hooks/useLogoVariant.ts
new file mode 100644
index 000000000..3df447c09
--- /dev/null
+++ b/frontend/src/core/hooks/useLogoVariant.ts
@@ -0,0 +1,18 @@
+import { useMemo } from 'react';
+import { usePreferences } from '@app/contexts/PreferencesContext';
+import { useAppConfig } from '@app/contexts/AppConfigContext';
+import type { LogoVariant } from '@app/services/preferencesService';
+import { ensureLogoVariant } from '@app/constants/logo';
+
+export function useLogoVariant(): LogoVariant {
+ const { preferences } = usePreferences();
+ const { config } = useAppConfig();
+
+ return useMemo(() => {
+ // Check local storage first, then fall back to server config
+ const preferenceVariant = preferences.logoVariant;
+ const configVariant = config?.logoStyle;
+ return ensureLogoVariant(preferenceVariant ?? configVariant);
+ }, [config?.logoStyle, preferences.logoVariant]);
+}
+
diff --git a/frontend/src/core/pages/HomePage.tsx b/frontend/src/core/pages/HomePage.tsx
index cc9a3e783..e03f46ee2 100644
--- a/frontend/src/core/pages/HomePage.tsx
+++ b/frontend/src/core/pages/HomePage.tsx
@@ -4,11 +4,11 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { Group, useMantineColorScheme } from "@mantine/core";
import { useSidebarContext } from "@app/contexts/SidebarContext";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
-import { BASE_PATH } from "@app/constants/app";
import { useBaseUrl } from "@app/hooks/useBaseUrl";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useLogoPath } from "@app/hooks/useLogoPath";
+import { useLogoAssets } from '@app/hooks/useLogoAssets';
import { useCookieConsentContext } from "@app/contexts/CookieConsentContext";
import { useFileContext } from "@app/contexts/file/fileHooks";
import { useNavigationActions } from "@app/contexts/NavigationContext";
@@ -84,9 +84,8 @@ export default function HomePage() {
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const brandIconSrc = useLogoPath();
- const brandTextSrc = `${BASE_PATH}/branding/StirlingPDFLogo${
- colorScheme === "dark" ? "White" : "Black"
- }Text.svg`;
+ const { wordmark } = useLogoAssets();
+ const brandTextSrc = colorScheme === "dark" ? wordmark.white : wordmark.black;
const handleSelectMobileView = useCallback((view: MobileView) => {
setActiveMobileView(view);
diff --git a/frontend/src/core/services/preferencesService.ts b/frontend/src/core/services/preferencesService.ts
index 80c35e4b1..9fd472897 100644
--- a/frontend/src/core/services/preferencesService.ts
+++ b/frontend/src/core/services/preferencesService.ts
@@ -1,6 +1,8 @@
import { type ToolPanelMode, DEFAULT_TOOL_PANEL_MODE } from '@app/constants/toolPanel';
import { type ThemeMode, getSystemTheme } from '@app/constants/theme';
+export type LogoVariant = 'modern' | 'classic';
+
export interface UserPreferences {
autoUnzip: boolean;
autoUnzipFileLimit: number;
@@ -14,6 +16,7 @@ export interface UserPreferences {
hasSeenCookieBanner: boolean;
hideUnavailableTools: boolean;
hideUnavailableConversions: boolean;
+ logoVariant: LogoVariant | null;
}
export const DEFAULT_PREFERENCES: UserPreferences = {
@@ -29,6 +32,7 @@ export const DEFAULT_PREFERENCES: UserPreferences = {
hasSeenCookieBanner: false,
hideUnavailableTools: false,
hideUnavailableConversions: false,
+ logoVariant: null,
};
const STORAGE_KEY = 'stirlingpdf_preferences';
diff --git a/frontend/src/proprietary/components/AppProviders.tsx b/frontend/src/proprietary/components/AppProviders.tsx
index a0e9a63dc..e92cd5573 100644
--- a/frontend/src/proprietary/components/AppProviders.tsx
+++ b/frontend/src/proprietary/components/AppProviders.tsx
@@ -2,9 +2,9 @@ import { AppProviders as CoreAppProviders, AppProvidersProps } from "@core/compo
import { AuthProvider } from "@app/auth/UseSession";
import { LicenseProvider } from "@app/contexts/LicenseContext";
import { CheckoutProvider } from "@app/contexts/CheckoutContext";
+import { UpdateSeatsProvider } from "@app/contexts/UpdateSeatsContext"
import { UpgradeBannerInitializer } from "@app/components/shared/UpgradeBannerInitializer";
import { ServerExperienceProvider } from "@app/contexts/ServerExperienceContext";
-import { UpdateSeatsProvider } from "@app/contexts/UpdateSeatsContext";
export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) {
return (
diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx
index 3e2b445a6..b3bc6c99d 100644
--- a/frontend/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx
+++ b/frontend/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx
@@ -1,4 +1,4 @@
-import { useEffect } from 'react';
+import { useEffect, useState, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect, Badge, SegmentedControl } from '@mantine/core';
import { alert } from '@app/components/toast';
@@ -9,6 +9,8 @@ import PendingBadge from '@app/components/shared/config/PendingBadge';
import apiClient from '@app/services/apiClient';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
+import { usePreferences } from '@app/contexts/PreferencesContext';
+import { useUnsavedChanges } from '@app/contexts/UnsavedChangesContext';
interface GeneralSettingsData {
ui: {
@@ -45,6 +47,13 @@ export default function AdminGeneralSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
+ const { preferences, updatePreference } = usePreferences();
+ const { setIsDirty, markClean } = useUnsavedChanges();
+
+ // Track original settings for dirty detection
+ const [originalSettingsSnapshot, setOriginalSettingsSnapshot] = useState('');
+ const [isDirty, setLocalIsDirty] = useState(false);
+ const isInitialLoad = useRef(true);
const {
settings,
@@ -149,9 +158,79 @@ export default function AdminGeneralSection() {
}
}, [loginEnabled, fetchSettings]);
+ // Snapshot original settings after initial load and sync local preference with server
+ useEffect(() => {
+ if (!loading && isInitialLoad.current && Object.keys(settings).length > 0) {
+ setOriginalSettingsSnapshot(JSON.stringify(settings));
+
+ // Sync local preference with server setting on initial load to ensure they're in sync
+ // This ensures localStorage always reflects the server's authoritative value
+ if (loginEnabled && settings.ui?.logoStyle) {
+ updatePreference('logoVariant', settings.ui.logoStyle);
+ }
+
+ isInitialLoad.current = false;
+ }
+ }, [loading, settings, loginEnabled, updatePreference]);
+
+ // Track dirty state by comparing current settings to snapshot
+ useEffect(() => {
+ if (!originalSettingsSnapshot || loading) return;
+
+ const currentSnapshot = JSON.stringify(settings);
+ const dirty = currentSnapshot !== originalSettingsSnapshot;
+ setLocalIsDirty(dirty);
+ setIsDirty(dirty);
+ }, [settings, originalSettingsSnapshot, loading, setIsDirty]);
+
+ // Clean up dirty state on unmount
+ useEffect(() => {
+ return () => {
+ setIsDirty(false);
+ };
+ }, [setIsDirty]);
+
+ const handleDiscard = useCallback(() => {
+ if (originalSettingsSnapshot) {
+ try {
+ const original = JSON.parse(originalSettingsSnapshot);
+ setSettings(original);
+ setLocalIsDirty(false);
+ setIsDirty(false);
+ } catch (e) {
+ console.error('Failed to parse original settings:', e);
+ }
+ }
+ }, [originalSettingsSnapshot, setSettings, setIsDirty]);
+
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
+ // Show the server setting when loaded (for admin config), otherwise show user's preference
+ // Note: User's preference in localStorage is separate and takes precedence in the app via useLogoVariant hook
+ const logoStyleValue = loginEnabled
+ ? (settings.ui?.logoStyle ?? preferences.logoVariant ?? 'classic')
+ : (preferences.logoVariant ?? 'classic');
+
+ const handleLogoStyleChange = (value: string) => {
+ const nextValue = value === 'modern' ? 'modern' : 'classic';
+
+ // Only update local settings state - don't update the actual preference until save
+ // When login is disabled, update preference immediately since there's no server to save to
+ if (!loginEnabled) {
+ updatePreference('logoVariant', nextValue);
+ return;
+ }
+
+ setSettings({
+ ...settings,
+ ui: {
+ ...settings.ui,
+ logoStyle: nextValue,
+ }
+ });
+ };
+
const handleSave = async () => {
// Block save if login is disabled
if (!validateLoginEnabled()) {
@@ -160,6 +239,16 @@ export default function AdminGeneralSection() {
try {
await saveSettings();
+
+ // Update local preference after successful save so the app reflects the saved logo style
+ if (settings.ui?.logoStyle) {
+ updatePreference('logoVariant', settings.ui.logoStyle);
+ }
+
+ // Update snapshot to current settings after successful save
+ setOriginalSettingsSnapshot(JSON.stringify(settings));
+ setLocalIsDirty(false);
+ markClean();
showRestartModal();
} catch (_error) {
alert({
@@ -179,8 +268,9 @@ export default function AdminGeneralSection() {
}
return (
-
-
+
+
+
{t('admin.settings.general.title', 'System Settings')}
@@ -221,15 +311,15 @@ export default function AdminGeneralSection() {
{t('admin.settings.general.logoStyle.description', 'Choose between the modern minimalist logo or the classic S icon')}
setSettings({ ...settings, ui: { ...settings.ui, logoStyle: value as 'modern' | 'classic' } })}
+ value={logoStyleValue}
+ onChange={handleLogoStyleChange}
data={[
{
value: 'classic',
label: (

@@ -242,7 +332,7 @@ export default function AdminGeneralSection() {
label: (

@@ -251,7 +341,6 @@ export default function AdminGeneralSection() {
)
},
]}
- disabled={!loginEnabled}
/>
@@ -586,12 +675,26 @@ export default function AdminGeneralSection() {
- {/* Save Button */}
-
-
-
+
+
+ {/* Sticky Save Footer - only shows when there are changes */}
+ {isDirty && loginEnabled && (
+
+
+
+ {t('admin.settings.unsavedChanges.hint', 'You have unsaved changes')}
+
+
+
+
+
+
+
+ )}
{/* Restart Confirmation Modal */}
-
+
);
}
diff --git a/frontend/src/proprietary/components/shared/loginSlides.ts b/frontend/src/proprietary/components/shared/loginSlides.ts
index 167b2e6c5..51c507237 100644
--- a/frontend/src/proprietary/components/shared/loginSlides.ts
+++ b/frontend/src/proprietary/components/shared/loginSlides.ts
@@ -1,43 +1,60 @@
import { BASE_PATH } from '@app/constants/app';
+import { getLogoFolder } from '@app/constants/logo';
+import type { LogoVariant } from '@app/services/preferencesService';
+import type { TFunction } from 'i18next';
export type LoginCarouselSlide = {
- src: string
- alt?: string
- title?: string
- subtitle?: string
- cornerModelUrl?: string
- followMouseTilt?: boolean
- tiltMaxDeg?: number
-}
+ src: string;
+ alt?: string;
+ title?: string;
+ subtitle?: string;
+ cornerModelUrl?: string;
+ followMouseTilt?: boolean;
+ tiltMaxDeg?: number;
+};
-export const loginSlides: LoginCarouselSlide[] = [
- {
- src: `${BASE_PATH}/Login/Firstpage.png`,
- alt: 'Stirling PDF overview',
- title: 'Your one-stop-shop for all your PDF needs.',
- subtitle:
- 'A privacy-first cloud suite for PDFs that lets you convert, sign, redact, and manage documents, along with 50+ other powerful tools.',
- followMouseTilt: true,
- tiltMaxDeg: 5,
- },
- {
- src: `${BASE_PATH}/Login/AddToPDF.png`,
- alt: 'Edit PDFs',
- title: 'Edit PDFs to display/secure the information you want',
- subtitle:
- 'With over a dozen tools to help you redact, sign, read and manipulate PDFs, you will be sure to find what you are looking for.',
- followMouseTilt: true,
- tiltMaxDeg: 5,
- },
- {
- src: `${BASE_PATH}/Login/SecurePDF.png`,
- alt: 'Secure PDFs',
- title: 'Protect sensitive information in your PDFs',
- subtitle:
- 'Add passwords, redact content, and manage certificates with ease.',
- followMouseTilt: true,
- tiltMaxDeg: 5,
- },
-];
+export const buildLoginSlides = (
+ variant: LogoVariant | null | undefined,
+ t: TFunction
+): LoginCarouselSlide[] => {
+ const folder = getLogoFolder(variant);
+ const heroImage = `${BASE_PATH}/${folder}/Firstpage.png`;
-export default loginSlides;
+ return [
+ {
+ src: heroImage,
+ alt: t('login.slides.overview.alt', 'Stirling PDF overview'),
+ title: t('login.slides.overview.title', 'Your one-stop-shop for all your PDF needs.'),
+ subtitle: t(
+ 'login.slides.overview.subtitle',
+ 'A privacy-first cloud suite for PDFs that lets you convert, sign, redact, and manage documents, along with 50+ other powerful tools.'
+ ),
+ followMouseTilt: true,
+ tiltMaxDeg: 5,
+ },
+ {
+ src: `${BASE_PATH}/Login/AddToPDF.png`,
+ alt: t('login.slides.edit.alt', 'Edit PDFs'),
+ title: t('login.slides.edit.title', 'Edit PDFs to display/secure the information you want'),
+ subtitle: t(
+ 'login.slides.edit.subtitle',
+ 'With over a dozen tools to help you redact, sign, read and manipulate PDFs, you will be sure to find what you are looking for.'
+ ),
+ followMouseTilt: true,
+ tiltMaxDeg: 5,
+ },
+ {
+ src: `${BASE_PATH}/Login/SecurePDF.png`,
+ alt: t('login.slides.secure.alt', 'Secure PDFs'),
+ title: t('login.slides.secure.title', 'Protect sensitive information in your PDFs'),
+ subtitle: t(
+ 'login.slides.secure.subtitle',
+ 'Add passwords, redact content, and manage certificates with ease.'
+ ),
+ followMouseTilt: true,
+ tiltMaxDeg: 5,
+ },
+ ];
+};
+
+export default buildLoginSlides;
diff --git a/frontend/src/proprietary/routes/Login.test.tsx b/frontend/src/proprietary/routes/Login.test.tsx
index e279212e5..16a6cb5e6 100644
--- a/frontend/src/proprietary/routes/Login.test.tsx
+++ b/frontend/src/proprietary/routes/Login.test.tsx
@@ -6,6 +6,7 @@ import { MantineProvider } from '@mantine/core';
import Login from '@app/routes/Login';
import { useAuth } from '@app/auth/UseSession';
import { springAuth } from '@app/auth/springAuthClient';
+import { PreferencesProvider } from '@app/contexts/PreferencesContext';
// Mock i18n to return fallback text
vi.mock('react-i18next', () => ({
@@ -49,7 +50,9 @@ vi.mock('react-router-dom', async () => {
// Test wrapper with MantineProvider
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
- {children}
+
+ {children}
+
);
describe('Login', () => {
diff --git a/frontend/src/proprietary/routes/authShared/AuthLayout.tsx b/frontend/src/proprietary/routes/authShared/AuthLayout.tsx
index ba7df3795..20ca26d9a 100644
--- a/frontend/src/proprietary/routes/authShared/AuthLayout.tsx
+++ b/frontend/src/proprietary/routes/authShared/AuthLayout.tsx
@@ -1,15 +1,20 @@
-import React, { useEffect, useRef, useState } from 'react';
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
import LoginRightCarousel from '@app/components/shared/LoginRightCarousel';
-import loginSlides from '@app/components/shared/loginSlides';
+import buildLoginSlides from '@app/components/shared/loginSlides';
import styles from '@app/routes/authShared/AuthLayout.module.css';
+import { useLogoVariant } from '@app/hooks/useLogoVariant';
interface AuthLayoutProps {
children: React.ReactNode
}
export default function AuthLayout({ children }: AuthLayoutProps) {
+ const { t } = useTranslation();
const cardRef = useRef(null);
const [hideRightPanel, setHideRightPanel] = useState(false);
+ const logoVariant = useLogoVariant();
+ const imageSlides = useMemo(() => buildLoginSlides(logoVariant, t), [logoVariant, t]);
// Force light mode on auth pages
useEffect(() => {
@@ -60,7 +65,7 @@ export default function AuthLayout({ children }: AuthLayoutProps) {
{!hideRightPanel && (
-
+
)}
diff --git a/frontend/src/proprietary/routes/login/LoginHeader.tsx b/frontend/src/proprietary/routes/login/LoginHeader.tsx
index a6317976d..01b1a2074 100644
--- a/frontend/src/proprietary/routes/login/LoginHeader.tsx
+++ b/frontend/src/proprietary/routes/login/LoginHeader.tsx
@@ -1,5 +1,5 @@
-import { BASE_PATH } from '@app/constants/app';
+import { useLogoAssets } from '@app/hooks/useLogoAssets';
interface LoginHeaderProps {
title: string
@@ -7,10 +7,12 @@ interface LoginHeaderProps {
}
export default function LoginHeader({ title, subtitle }: LoginHeaderProps) {
+ const { wordmark } = useLogoAssets();
+
return (
-

+
{title}
{subtitle && (