mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Add SaaS frontend code (#5879)
# Description of Changes Adds the code for the SaaS frontend as proprietary code to the OSS repo. This version of the code is adapted from 22/1/2026, which was the last SaaS version based on the 'V2' design. This will move us closer to being able to have the OSS products understand whether the user has a SaaS account, and provide the correct UI in those cases.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Core stub for HomePage extensions.
|
||||
*/
|
||||
|
||||
export function HomePageExtensions() {
|
||||
return null;
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '@app/components/shared/quickAccessBar/QuickAccessBar';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
import { QuickAccessBarFooterExtensions } from '@app/components/quickAccessBar/QuickAccessBarFooterExtensions';
|
||||
import { useConfigButtonIcon } from '@app/hooks/useConfigButtonIcon';
|
||||
|
||||
const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -44,6 +45,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const [activeButton, setActiveButton] = useState<string>('tools');
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
const configButtonIcon = useConfigButtonIcon();
|
||||
|
||||
const {
|
||||
tooltipOpen,
|
||||
manualCloseOnly,
|
||||
@@ -202,7 +205,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
...(shouldHideSettingsButton ? [] : [{
|
||||
id: 'config',
|
||||
name: t("quickAccess.settings", "Settings"),
|
||||
icon: <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
|
||||
icon: configButtonIcon ?? <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
|
||||
size: 'md' as const,
|
||||
type: 'modal' as const,
|
||||
onClick: () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import type { ConfigNavSection, ConfigNavItem } from '@core/components/shared/config/configNavSections';
|
||||
import type { ConfigNavSection, ConfigNavItem } from '@app/components/shared/config/configNavSections';
|
||||
|
||||
interface SettingsSearchBarProps {
|
||||
configNavSections: ConfigNavSection[];
|
||||
|
||||
@@ -30,9 +30,11 @@ const BANNER_DISMISSED_KEY = "stirlingpdf_features_banner_dismissed";
|
||||
|
||||
interface GeneralSectionProps {
|
||||
hideTitle?: boolean;
|
||||
hideUpdateSection?: boolean;
|
||||
hideAdminBanner?: boolean;
|
||||
}
|
||||
|
||||
const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) => {
|
||||
const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false, hideUpdateSection = false, hideAdminBanner = false }) => {
|
||||
const { t } = useTranslation();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { config } = useAppConfig();
|
||||
@@ -156,7 +158,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loginDisabled && !bannerDismissed && (
|
||||
{!hideAdminBanner && loginDisabled && !bannerDismissed && (
|
||||
<Paper withBorder p="md" radius="md" style={{ background: "var(--mantine-color-blue-0)", position: "relative" }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
@@ -215,7 +217,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
|
||||
)}
|
||||
|
||||
{/* Update Check Section */}
|
||||
{config?.appVersion && (
|
||||
{!hideUpdateSection && config?.appVersion && (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { createContext, useContext, useState, useEffect, ReactNode, useCa
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { getSimulatedAppConfig } from '@app/testing/serverExperienceSimulations';
|
||||
import type { AppConfig, AppConfigBootstrapMode } from '@app/types/appConfig';
|
||||
import { useJwtConfigSync } from '@app/hooks/useJwtConfigSync';
|
||||
|
||||
/**
|
||||
* Sleep utility for delays
|
||||
@@ -41,6 +42,7 @@ export interface AppConfigProviderProps {
|
||||
initialConfig?: AppConfig | null;
|
||||
bootstrapMode?: AppConfigBootstrapMode;
|
||||
autoFetch?: boolean;
|
||||
onConfigLoaded?: (config: AppConfig) => void;
|
||||
}
|
||||
|
||||
export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
|
||||
@@ -49,6 +51,7 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
|
||||
initialConfig = null,
|
||||
bootstrapMode = 'blocking',
|
||||
autoFetch = true,
|
||||
onConfigLoaded,
|
||||
}) => {
|
||||
const isBlockingMode = bootstrapMode === 'blocking';
|
||||
const [config, setConfig] = useState<AppConfig | null>(initialConfig);
|
||||
@@ -58,6 +61,9 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
|
||||
const [hasResolvedConfig, setHasResolvedConfig] = useState(Boolean(initialConfig) && !isBlockingMode);
|
||||
const [loading, setLoading] = useState(!hasResolvedConfig);
|
||||
|
||||
const onConfigLoadedRef = React.useRef(onConfigLoaded);
|
||||
onConfigLoadedRef.current = onConfigLoaded;
|
||||
|
||||
const maxRetries = retryOptions?.maxRetries ?? 0;
|
||||
const initialDelay = retryOptions?.initialDelay ?? 1000;
|
||||
|
||||
@@ -113,6 +119,7 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
|
||||
setConfig(data);
|
||||
setHasResolvedConfig(true);
|
||||
setLoading(false);
|
||||
onConfigLoadedRef.current?.(data);
|
||||
return; // Success - exit function
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status;
|
||||
@@ -151,42 +158,21 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
|
||||
setLoading(false);
|
||||
}, [hasResolvedConfig, isBlockingMode, maxRetries, initialDelay]);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip config fetch on auth pages (/login, /signup, /auth/callback, /invite/*)
|
||||
// Config will be fetched after successful authentication via jwt-available event
|
||||
const currentPath = window.location.pathname;
|
||||
const isAuthPage = currentPath.includes('/login') ||
|
||||
currentPath.includes('/signup') ||
|
||||
currentPath.includes('/auth/callback') ||
|
||||
currentPath.includes('/invite/');
|
||||
const { isAuthPage } = useJwtConfigSync(fetchConfig);
|
||||
|
||||
// On auth pages, always skip the config fetch
|
||||
// The config will be fetched after authentication via jwt-available event
|
||||
useEffect(() => {
|
||||
if (isAuthPage) {
|
||||
console.debug('[AppConfig] On auth page - using default config, skipping fetch', { path: currentPath });
|
||||
console.debug('[AppConfig] On auth page - using default config, skipping fetch', { path: window.location.pathname });
|
||||
setConfig({ enableLogin: true });
|
||||
setHasResolvedConfig(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// On non-auth pages, fetch config (will validate JWT if present)
|
||||
if (autoFetch) {
|
||||
fetchConfig();
|
||||
}
|
||||
}, [autoFetch, fetchConfig]);
|
||||
|
||||
// Listen for JWT availability (triggered on login/signup)
|
||||
useEffect(() => {
|
||||
const handleJwtAvailable = () => {
|
||||
console.debug('[AppConfig] JWT available event - refetching config');
|
||||
// Force refetch with JWT
|
||||
fetchConfig(true);
|
||||
};
|
||||
|
||||
window.addEventListener('jwt-available', handleJwtAvailable);
|
||||
return () => window.removeEventListener('jwt-available', handleJwtAvailable);
|
||||
}, [fetchConfig]);
|
||||
}, [autoFetch, fetchConfig, isAuthPage]);
|
||||
|
||||
const refetch = useCallback(() => fetchConfig(true), [fetchConfig]);
|
||||
|
||||
|
||||
@@ -121,13 +121,11 @@ export const useToolOperation = <TParams>(
|
||||
? config.endpoint(params)
|
||||
: config.endpoint;
|
||||
|
||||
// Credit check for cloud operations (desktop SaaS mode only, no-op in web builds)
|
||||
if (willUseCloud && endpoint) {
|
||||
const creditError = await checkCredits();
|
||||
if (creditError !== null) {
|
||||
actions.setError(creditError);
|
||||
return;
|
||||
}
|
||||
// Credit check — no-op in core builds, real check in desktop/SaaS versions
|
||||
const creditError = await checkCredits();
|
||||
if (creditError !== null) {
|
||||
actions.setError(creditError);
|
||||
return;
|
||||
}
|
||||
|
||||
// Backend readiness check (will skip for SaaS-routed endpoints)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Core stub — config button uses the default settings icon.
|
||||
*/
|
||||
export function useConfigButtonIcon(): React.ReactNode {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Core implementation: sets up the jwt-available event listener for OSS JWT auth,
|
||||
* and detects auth pages where config fetch should be skipped.
|
||||
*/
|
||||
export function useJwtConfigSync(fetchConfig: (force?: boolean) => void): { isAuthPage: boolean } {
|
||||
const currentPath = window.location.pathname;
|
||||
const isAuthPage =
|
||||
currentPath.includes('/login') ||
|
||||
currentPath.includes('/signup') ||
|
||||
currentPath.includes('/auth/callback') ||
|
||||
currentPath.includes('/invite/');
|
||||
|
||||
useEffect(() => {
|
||||
const handleJwtAvailable = () => {
|
||||
console.debug('[AppConfig] JWT available event - refetching config');
|
||||
fetchConfig(true);
|
||||
};
|
||||
|
||||
window.addEventListener('jwt-available', handleJwtAvailable);
|
||||
return () => window.removeEventListener('jwt-available', handleJwtAvailable);
|
||||
}, [fetchConfig]);
|
||||
|
||||
return { isAuthPage };
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import AppConfigModal from "@app/components/shared/AppConfigModal";
|
||||
import { getStartupNavigationAction } from "@app/utils/homePageNavigation";
|
||||
import { HomePageExtensions } from "@app/components/home/HomePageExtensions";
|
||||
|
||||
import "@app/pages/HomePage.css";
|
||||
|
||||
@@ -204,6 +205,7 @@ export default function HomePage() {
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-hidden">
|
||||
<HomePageExtensions />
|
||||
{isMobile ? (
|
||||
<div className="mobile-layout">
|
||||
<div className="mobile-toggle">
|
||||
|
||||
@@ -13,3 +13,13 @@
|
||||
@layer utilities {
|
||||
@tailwind utilities;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "../../",
|
||||
"paths": {
|
||||
"@app/*": [
|
||||
"src/core/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"../global.d.ts",
|
||||
"../*.js",
|
||||
"../*.ts",
|
||||
"../*.tsx",
|
||||
"."
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user